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:
2026-04-07 00:20:32 -04:00
parent 7daec1c2e2
commit a2388b3ab2
9 changed files with 370 additions and 19 deletions

View File

@@ -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)

View File

@@ -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,
},
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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')
})
})