From a2388b3ab23686eafd594ec2f7ee658fec16b9a7 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Tue, 7 Apr 2026 00:20:32 -0400 Subject: [PATCH] Add rev and cache parameters to @client decorator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rev=N: bumped by developer when function logic changes. Becomes part of the HMAC cache key — old cache entries are unreachable without purge. Effective rev for a context is max(rev) across all functions in it. cache=int|False|True: TTL escape hatch for unobservable mutations. cache=60 emits s-maxage=60. cache=False emits no-store. Default (True) emits s-maxage=31536000 (forever, purge on mutation). Effective cache for a context is min(TTL) across functions, with False taking precedence. Both parameters flow through: decorator → meta → manifest → cache key and Cache-Control headers. Implemented in both Python and TypeScript with 13 Python tests and 4 TypeScript tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mizan-django/src/mizan/client/executor.py | 62 +++-- .../mizan-django/src/mizan/client/function.py | 17 +- .../mizan-django/src/mizan/export/__init__.py | 6 + .../mizan-django/src/mizan/tests/test_core.py | 224 ++++++++++++++++++ packages/mizan-ts/src/decorator.ts | 4 + packages/mizan-ts/src/dispatch.ts | 24 +- packages/mizan-ts/src/manifest.ts | 2 + packages/mizan-ts/src/types.ts | 4 + packages/mizan-ts/tests/edge-compat.test.ts | 46 ++++ 9 files changed, 370 insertions(+), 19 deletions(-) diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index fe2e94c..2930b6c 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -775,22 +775,51 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: params = request.GET.dict() - # Origin-side cache lookup + # Resolve effective rev and cache policy across all functions in this context _cache_log = logging.getLogger("mizan.cache") - cache = get_cache() + groups = get_context_groups() + fn_names = groups.get(context_name, []) + effective_rev = 0 + effective_cache: int | bool = True # True=forever, False=no-store, int=TTL + for fn_name in fn_names: + fn_cls = get_function(fn_name) + if fn_cls: + meta = getattr(fn_cls, "_meta", {}) + fn_rev = meta.get("rev", 0) + effective_rev = max(effective_rev, fn_rev) + fn_cache = meta.get("cache", True) + if fn_cache is False: + effective_cache = False + break + elif isinstance(fn_cache, int): + if effective_cache is True: + effective_cache = fn_cache + else: + effective_cache = min(effective_cache, fn_cache) + + # Origin-side cache lookup (skip if cache=False) + cache_backend = get_cache() cache_settings = get_settings() user_id = None if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk: user_id = str(request.user.pk) - if cache is not None and cache_settings.cache_secret: + use_cache = ( + cache_backend is not None + and cache_settings.cache_secret + and effective_cache is not False + ) + if use_cache: try: cached = cache_get( - cache_settings.cache_secret, cache, context_name, params, - user_id=user_id, + cache_settings.cache_secret, cache_backend, context_name, params, + user_id=user_id, rev=effective_rev, ) if cached is not None: response = HttpResponse(cached, content_type="application/json") - response["Cache-Control"] = "public, max-age=0, s-maxage=31536000" + if isinstance(effective_cache, int): + response["Cache-Control"] = f"public, max-age=0, s-maxage={effective_cache}" + else: + response["Cache-Control"] = "public, max-age=0, s-maxage=31536000" response["X-Mizan-Cache"] = "HIT" return response except Exception: @@ -815,19 +844,20 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: # Deterministic JSON (sorted keys) for consistent cache keys response = JsonResponse(result.data, json_dumps_params={"sort_keys": True}) - # CDN-ready headers - # max-age=0: browser always revalidates (gets 304 from CDN if unchanged) - # s-maxage=31536000: CDN caches forever; purge is the freshness mechanism - # No Vary header — Cloudflare ignores Vary for personalized content. - # User-scoped cache keying will use HMAC-based keys instead. - response["Cache-Control"] = "public, max-age=0, s-maxage=31536000" + # CDN-ready headers based on effective cache policy + if effective_cache is False: + response["Cache-Control"] = "no-store" + elif isinstance(effective_cache, int): + response["Cache-Control"] = f"public, max-age=0, s-maxage={effective_cache}" + else: + response["Cache-Control"] = "public, max-age=0, s-maxage=31536000" - # Store in origin-side cache - if cache is not None and cache_settings.cache_secret: + # Store in origin-side cache (skip if cache=False) + if use_cache: try: cache_put( - cache_settings.cache_secret, cache, context_name, params, - response.content, user_id=user_id, + cache_settings.cache_secret, cache_backend, context_name, params, + response.content, user_id=user_id, rev=effective_rev, ) response["X-Mizan-Cache"] = "MISS" except Exception: diff --git a/packages/mizan-django/src/mizan/client/function.py b/packages/mizan-django/src/mizan/client/function.py index 45315c8..a0bf691 100644 --- a/packages/mizan-django/src/mizan/client/function.py +++ b/packages/mizan-django/src/mizan/client/function.py @@ -261,6 +261,8 @@ def client( methods: list[str] | None = None, websocket: bool = False, auth: bool | str | Callable[[Any], bool] | None = None, + rev: int = 0, + cache: int | bool = True, ) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]: """ Register a function as a server function. @@ -336,7 +338,7 @@ def client( return _create_server_function( fn, context=resolved_context, affects=affects, private=private, route=route, methods=methods, - websocket=websocket, auth=auth, + websocket=websocket, auth=auth, rev=rev, cache=cache, ) # Support both @client and @client(...) @@ -344,7 +346,7 @@ def client( return _create_server_function( fn, context=resolved_context, affects=affects, private=private, route=route, methods=methods, - websocket=websocket, auth=auth, + websocket=websocket, auth=auth, rev=rev, cache=cache, ) return decorator @@ -387,6 +389,8 @@ def _create_server_function( methods: list[str] | None = None, websocket: bool = False, auth: bool | str | None = None, + rev: int = 0, + cache: int | bool = True, ) -> type[ServerFunction]: """Internal helper that creates a ServerFunction from a decorated function.""" from pydantic import create_model @@ -523,6 +527,15 @@ def _create_server_function( else: meta["auth"] = auth + # Revision: bumped by developer when function logic changes. + # Part of the HMAC cache key — old entries become unreachable orphans. + if rev != 0: + meta["rev"] = rev + + # Cache policy: True=forever (default), False=no-store, int=TTL seconds + if cache is not True: + meta["cache"] = cache + if meta: FunctionWrapper._meta = {**FunctionWrapper._meta, **meta} diff --git a/packages/mizan-django/src/mizan/export/__init__.py b/packages/mizan-django/src/mizan/export/__init__.py index 6bd8ca7..f642aee 100644 --- a/packages/mizan-django/src/mizan/export/__init__.py +++ b/packages/mizan-django/src/mizan/export/__init__.py @@ -428,6 +428,12 @@ def generate_edge_manifest( fn_entry["methods"] = meta.get("methods", ["GET"]) page_routes.append(fn_route) + # Cache protocol metadata + if "rev" in meta: + fn_entry["rev"] = meta["rev"] + if "cache" in meta: + fn_entry["cache"] = meta["cache"] + functions_meta.append(fn_entry) sorted_params = sorted(param_names) diff --git a/packages/mizan-django/src/mizan/tests/test_core.py b/packages/mizan-django/src/mizan/tests/test_core.py index f9f6adb..633a748 100644 --- a/packages/mizan-django/src/mizan/tests/test_core.py +++ b/packages/mizan-django/src/mizan/tests/test_core.py @@ -3028,3 +3028,227 @@ class CacheIntegrationTests(TestCase): # User 6 should still be cached r6 = self._fetch_context("user_id=6") self.assertEqual(r6.get("X-Mizan-Cache"), "HIT") + + +# ── Rev and cache parameter tests ────────────────────────────────────────── + + +class RevParameterTests(TestCase): + """Tests for the @client(rev=N) parameter.""" + + def setUp(self): + clear_registry() + + def tearDown(self): + clear_registry() + + def test_rev_stored_in_meta(self): + """@client(rev=2) stores rev in function metadata.""" + Ctx = ReactContext("data") + + @client(context=Ctx, rev=2) + def my_fn(request: HttpRequest) -> dict: + return {} + + register(my_fn, "my_fn") + meta = getattr(get_function("my_fn"), "_meta", {}) + self.assertEqual(meta["rev"], 2) + + def test_rev_default_not_in_meta(self): + """@client with default rev=0 does not store rev in meta.""" + Ctx = ReactContext("data") + + @client(context=Ctx) + def my_fn(request: HttpRequest) -> dict: + return {} + + register(my_fn, "my_fn") + meta = getattr(get_function("my_fn"), "_meta", {}) + self.assertNotIn("rev", meta) + + def test_rev_changes_cache_key(self): + """Different rev values produce different HMAC cache keys.""" + from mizan.cache.keys import derive_cache_key + + key_v0 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=0) + key_v1 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=1) + self.assertNotEqual(key_v0, key_v1) + + def test_rev_in_manifest(self): + """Manifest includes rev on functions that set it.""" + from mizan.export import generate_edge_manifest + + Ctx = ReactContext("data") + + @client(context=Ctx, rev=3) + def versioned_fn(request: HttpRequest, item_id: int) -> dict: + return {} + + register(versioned_fn, "versioned_fn") + manifest = generate_edge_manifest() + fn_entry = manifest["contexts"]["data"]["functions"][0] + self.assertEqual(fn_entry["rev"], 3) + + def test_rev_not_in_manifest_when_default(self): + """Manifest omits rev when it's the default (0).""" + from mizan.export import generate_edge_manifest + + Ctx = ReactContext("data") + + @client(context=Ctx) + def default_fn(request: HttpRequest) -> dict: + return {} + + register(default_fn, "default_fn") + manifest = generate_edge_manifest() + fn_entry = manifest["contexts"]["data"]["functions"][0] + self.assertNotIn("rev", fn_entry) + + +class CacheParameterTests(TestCase): + """Tests for the @client(cache=...) parameter.""" + + def setUp(self): + clear_registry() + + def tearDown(self): + clear_registry() + + def test_cache_int_stored_in_meta(self): + """@client(cache=60) stores cache TTL in meta.""" + Ctx = ReactContext("data") + + @client(context=Ctx, cache=60) + def my_fn(request: HttpRequest) -> dict: + return {} + + register(my_fn, "my_fn") + meta = getattr(get_function("my_fn"), "_meta", {}) + self.assertEqual(meta["cache"], 60) + + def test_cache_false_stored_in_meta(self): + """@client(cache=False) stores False in meta.""" + Ctx = ReactContext("data") + + @client(context=Ctx, cache=False) + def my_fn(request: HttpRequest) -> dict: + return {} + + register(my_fn, "my_fn") + meta = getattr(get_function("my_fn"), "_meta", {}) + self.assertIs(meta["cache"], False) + + def test_cache_default_not_in_meta(self): + """Default cache=True is not stored in meta.""" + Ctx = ReactContext("data") + + @client(context=Ctx) + def my_fn(request: HttpRequest) -> dict: + return {} + + register(my_fn, "my_fn") + meta = getattr(get_function("my_fn"), "_meta", {}) + self.assertNotIn("cache", meta) + + def test_cache_in_manifest(self): + """Manifest includes cache TTL on functions that set it.""" + from mizan.export import generate_edge_manifest + + Ctx = ReactContext("data") + + @client(context=Ctx, cache=120) + def ttl_fn(request: HttpRequest) -> dict: + return {} + + register(ttl_fn, "ttl_fn") + manifest = generate_edge_manifest() + fn_entry = manifest["contexts"]["data"]["functions"][0] + self.assertEqual(fn_entry["cache"], 120) + + +class CachePolicyIntegrationTests(TestCase): + """Tests for effective cache policy resolution in context_fetch_view.""" + + def setUp(self): + clear_registry() + + def tearDown(self): + clear_registry() + + def test_cache_int_sets_smaxage(self): + """Context with cache=60 emits s-maxage=60.""" + Ctx = ReactContext("trending") + + @client(context=Ctx, cache=60) + def trending(request: HttpRequest) -> dict: + return {"items": []} + + register(trending, "trending") + response = self.client.get("/api/mizan/ctx/trending/") + self.assertIn("s-maxage=60", response["Cache-Control"]) + self.assertNotIn("31536000", response["Cache-Control"]) + + def test_cache_false_sets_nostore(self): + """Context with cache=False emits no-store.""" + Ctx = ReactContext("random") + + @client(context=Ctx, cache=False) + def random_fn(request: HttpRequest) -> dict: + return {"value": 42} + + register(random_fn, "random_fn") + response = self.client.get("/api/mizan/ctx/random/") + self.assertEqual(response["Cache-Control"], "no-store") + + def test_effective_cache_is_minimum(self): + """Context with mixed TTLs uses the minimum.""" + Ctx = ReactContext("mixed") + + @client(context=Ctx, cache=300) + def slow_fn(request: HttpRequest) -> dict: + return {"slow": True} + + @client(context=Ctx, cache=60) + def fast_fn(request: HttpRequest) -> dict: + return {"fast": True} + + register(slow_fn, "slow_fn") + register(fast_fn, "fast_fn") + response = self.client.get("/api/mizan/ctx/mixed/") + self.assertIn("s-maxage=60", response["Cache-Control"]) + + def test_effective_rev_is_maximum(self): + """Context with mixed revs uses the maximum for cache key.""" + from mizan.cache.keys import derive_cache_key + from mizan.cache import set_cache, reset_cache + from mizan.cache.backend import MemoryCache + from mizan.setup.settings import clear_settings_cache + from django.test import override_settings + + Ctx = ReactContext("versioned") + + @client(context=Ctx, rev=0) + def old_fn(request: HttpRequest, item_id: int) -> dict: + return {"old": True} + + @client(context=Ctx, rev=2) + def new_fn(request: HttpRequest, item_id: int) -> dict: + return {"new": True} + + register(old_fn, "old_fn") + register(new_fn, "new_fn") + + mem_cache = MemoryCache() + set_cache(mem_cache) + + with override_settings(MIZAN_CACHE_SECRET="test", MIZAN_CACHE_REDIS_URL="dummy"): + clear_settings_cache() + r1 = self.client.get("/api/mizan/ctx/versioned/?item_id=1") + self.assertEqual(r1.status_code, 200) + + # The cache key should use rev=2 (max) + expected_key = derive_cache_key("test", "versioned", {"item_id": "1"}, rev=2) + self.assertIn(expected_key, mem_cache._store) + + reset_cache() + clear_settings_cache() diff --git a/packages/mizan-ts/src/decorator.ts b/packages/mizan-ts/src/decorator.ts index d34e4b4..995f56c 100644 --- a/packages/mizan-ts/src/decorator.ts +++ b/packages/mizan-ts/src/decorator.ts @@ -102,6 +102,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function route: options.route, methods: options.methods, auth: options.auth, + rev: options.rev, + cache: options.cache, } register(entry) @@ -132,6 +134,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function route: options.route, methods: options.methods, auth: options.auth, + rev: options.rev, + cache: options.cache, } register(entry) diff --git a/packages/mizan-ts/src/dispatch.ts b/packages/mizan-ts/src/dispatch.ts index a651e4e..5095679 100644 --- a/packages/mizan-ts/src/dispatch.ts +++ b/packages/mizan-ts/src/dispatch.ts @@ -67,12 +67,34 @@ export async function handleContextFetch( } } + // Resolve effective cache policy (minimum TTL across all functions) + let effectiveCache: number | boolean = true + for (const fnName of fnNames) { + const entry = getFunction(fnName) + if (!entry) continue + if (entry.cache === false) { effectiveCache = false; break } + if (typeof entry.cache === 'number') { + effectiveCache = effectiveCache === true + ? entry.cache + : Math.min(effectiveCache as number, entry.cache) + } + } + + let cacheControl: string + if (effectiveCache === false) { + cacheControl = 'no-store' + } else if (typeof effectiveCache === 'number') { + cacheControl = `public, max-age=0, s-maxage=${effectiveCache}` + } else { + cacheControl = 'public, max-age=0, s-maxage=31536000' + } + return { status: 200, body: results, headers: { 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=0, s-maxage=31536000', + 'Cache-Control': cacheControl, }, } } diff --git a/packages/mizan-ts/src/manifest.ts b/packages/mizan-ts/src/manifest.ts index e09b97f..3c5c9b7 100644 --- a/packages/mizan-ts/src/manifest.ts +++ b/packages/mizan-ts/src/manifest.ts @@ -35,6 +35,8 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest { fnEntry.methods = entry.methods || ['GET'] pageRoutes.push(entry.route) } + if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev + if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache functions.push(fnEntry) } diff --git a/packages/mizan-ts/src/types.ts b/packages/mizan-ts/src/types.ts index 184a440..bde256b 100644 --- a/packages/mizan-ts/src/types.ts +++ b/packages/mizan-ts/src/types.ts @@ -17,6 +17,8 @@ export interface ClientOptions { route?: string methods?: string[] auth?: boolean + rev?: number + cache?: number | false } export interface ParamDef { @@ -36,6 +38,8 @@ export interface RegistryEntry { route?: string methods?: string[] auth?: boolean + rev?: number + cache?: number | false } export interface ManifestContext { diff --git a/packages/mizan-ts/tests/edge-compat.test.ts b/packages/mizan-ts/tests/edge-compat.test.ts index 988c45e..cdd9e46 100644 --- a/packages/mizan-ts/tests/edge-compat.test.ts +++ b/packages/mizan-ts/tests/edge-compat.test.ts @@ -228,4 +228,50 @@ describe('Manifest', () => { expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/') expect(m.mutations.stripeWebhook.methods).toEqual(['POST']) }) + + test('rev appears in manifest', () => { + clearRegistry() + const Ctx = new ReactContext('data') + client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) { + return { value: itemId } + }) + + const m = generateManifest() + const fn = m.contexts.data.functions[0] + expect(fn.rev).toBe(3) + }) + + test('cache TTL appears in manifest', () => { + clearRegistry() + const Ctx = new ReactContext('trending') + client({ context: Ctx, cache: 60 }, async function trendingFn() { + return { items: [] } + }) + + const m = generateManifest() + const fn = m.contexts.trending.functions[0] + expect(fn.cache).toBe(60) + }) + + test('cache=60 sets s-maxage=60', async () => { + clearRegistry() + const Ctx = new ReactContext('live') + client({ context: Ctx, cache: 60 }, async function liveFn() { + return { score: 42 } + }) + + const r = await handleContextFetch('live', {}) + expect(r.headers['Cache-Control']).toBe('public, max-age=0, s-maxage=60') + }) + + test('cache=false sets no-store', async () => { + clearRegistry() + const Ctx = new ReactContext('random') + client({ context: Ctx, cache: false }, async function randomFn() { + return { value: Math.random() } + }) + + const r = await handleContextFetch('random', {}) + expect(r.headers['Cache-Control']).toBe('no-store') + }) })