Add rev and cache parameters to @client decorator
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) <noreply@anthropic.com>
This commit is contained in:
@@ -775,21 +775,50 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
|
|
||||||
params = request.GET.dict()
|
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_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()
|
cache_settings = get_settings()
|
||||||
user_id = None
|
user_id = None
|
||||||
if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk:
|
if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk:
|
||||||
user_id = str(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:
|
try:
|
||||||
cached = cache_get(
|
cached = cache_get(
|
||||||
cache_settings.cache_secret, cache, context_name, params,
|
cache_settings.cache_secret, cache_backend, context_name, params,
|
||||||
user_id=user_id,
|
user_id=user_id, rev=effective_rev,
|
||||||
)
|
)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
response = HttpResponse(cached, content_type="application/json")
|
response = HttpResponse(cached, content_type="application/json")
|
||||||
|
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["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
|
||||||
response["X-Mizan-Cache"] = "HIT"
|
response["X-Mizan-Cache"] = "HIT"
|
||||||
return response
|
return response
|
||||||
@@ -815,19 +844,20 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
# Deterministic JSON (sorted keys) for consistent cache keys
|
# Deterministic JSON (sorted keys) for consistent cache keys
|
||||||
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
||||||
|
|
||||||
# CDN-ready headers
|
# CDN-ready headers based on effective cache policy
|
||||||
# max-age=0: browser always revalidates (gets 304 from CDN if unchanged)
|
if effective_cache is False:
|
||||||
# s-maxage=31536000: CDN caches forever; purge is the freshness mechanism
|
response["Cache-Control"] = "no-store"
|
||||||
# No Vary header — Cloudflare ignores Vary for personalized content.
|
elif isinstance(effective_cache, int):
|
||||||
# User-scoped cache keying will use HMAC-based keys instead.
|
response["Cache-Control"] = f"public, max-age=0, s-maxage={effective_cache}"
|
||||||
|
else:
|
||||||
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
|
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
|
||||||
|
|
||||||
# Store in origin-side cache
|
# Store in origin-side cache (skip if cache=False)
|
||||||
if cache is not None and cache_settings.cache_secret:
|
if use_cache:
|
||||||
try:
|
try:
|
||||||
cache_put(
|
cache_put(
|
||||||
cache_settings.cache_secret, cache, context_name, params,
|
cache_settings.cache_secret, cache_backend, context_name, params,
|
||||||
response.content, user_id=user_id,
|
response.content, user_id=user_id, rev=effective_rev,
|
||||||
)
|
)
|
||||||
response["X-Mizan-Cache"] = "MISS"
|
response["X-Mizan-Cache"] = "MISS"
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ def client(
|
|||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | Callable[[Any], bool] | None = None,
|
auth: bool | str | Callable[[Any], bool] | None = None,
|
||||||
|
rev: int = 0,
|
||||||
|
cache: int | bool = True,
|
||||||
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
||||||
"""
|
"""
|
||||||
Register a function as a server function.
|
Register a function as a server function.
|
||||||
@@ -336,7 +338,7 @@ def client(
|
|||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects,
|
fn, context=resolved_context, affects=affects,
|
||||||
private=private, route=route, methods=methods,
|
private=private, route=route, methods=methods,
|
||||||
websocket=websocket, auth=auth,
|
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Support both @client and @client(...)
|
# Support both @client and @client(...)
|
||||||
@@ -344,7 +346,7 @@ def client(
|
|||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects,
|
fn, context=resolved_context, affects=affects,
|
||||||
private=private, route=route, methods=methods,
|
private=private, route=route, methods=methods,
|
||||||
websocket=websocket, auth=auth,
|
websocket=websocket, auth=auth, rev=rev, cache=cache,
|
||||||
)
|
)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
@@ -387,6 +389,8 @@ def _create_server_function(
|
|||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | None = None,
|
auth: bool | str | None = None,
|
||||||
|
rev: int = 0,
|
||||||
|
cache: int | bool = True,
|
||||||
) -> type[ServerFunction]:
|
) -> type[ServerFunction]:
|
||||||
"""Internal helper that creates a ServerFunction from a decorated function."""
|
"""Internal helper that creates a ServerFunction from a decorated function."""
|
||||||
from pydantic import create_model
|
from pydantic import create_model
|
||||||
@@ -523,6 +527,15 @@ def _create_server_function(
|
|||||||
else:
|
else:
|
||||||
meta["auth"] = auth
|
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:
|
if meta:
|
||||||
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
|
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
|
||||||
|
|
||||||
|
|||||||
@@ -428,6 +428,12 @@ def generate_edge_manifest(
|
|||||||
fn_entry["methods"] = meta.get("methods", ["GET"])
|
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||||
page_routes.append(fn_route)
|
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)
|
functions_meta.append(fn_entry)
|
||||||
|
|
||||||
sorted_params = sorted(param_names)
|
sorted_params = sorted(param_names)
|
||||||
|
|||||||
@@ -3028,3 +3028,227 @@ class CacheIntegrationTests(TestCase):
|
|||||||
# User 6 should still be cached
|
# User 6 should still be cached
|
||||||
r6 = self._fetch_context("user_id=6")
|
r6 = self._fetch_context("user_id=6")
|
||||||
self.assertEqual(r6.get("X-Mizan-Cache"), "HIT")
|
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()
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
|||||||
route: options.route,
|
route: options.route,
|
||||||
methods: options.methods,
|
methods: options.methods,
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
|
rev: options.rev,
|
||||||
|
cache: options.cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
register(entry)
|
register(entry)
|
||||||
@@ -132,6 +134,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
|||||||
route: options.route,
|
route: options.route,
|
||||||
methods: options.methods,
|
methods: options.methods,
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
|
rev: options.rev,
|
||||||
|
cache: options.cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
register(entry)
|
register(entry)
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: results,
|
body: results,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'public, max-age=0, s-maxage=31536000',
|
'Cache-Control': cacheControl,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
|||||||
fnEntry.methods = entry.methods || ['GET']
|
fnEntry.methods = entry.methods || ['GET']
|
||||||
pageRoutes.push(entry.route)
|
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)
|
functions.push(fnEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface ClientOptions {
|
|||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: boolean
|
auth?: boolean
|
||||||
|
rev?: number
|
||||||
|
cache?: number | false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParamDef {
|
export interface ParamDef {
|
||||||
@@ -36,6 +38,8 @@ export interface RegistryEntry {
|
|||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: boolean
|
auth?: boolean
|
||||||
|
rev?: number
|
||||||
|
cache?: number | false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManifestContext {
|
export interface ManifestContext {
|
||||||
|
|||||||
@@ -228,4 +228,50 @@ describe('Manifest', () => {
|
|||||||
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
|
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
|
||||||
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user