Cache
cacheMemoise any call with keys that carry the tenant and region, keep personal responses out of shared caches, and keep serving when the cache store is down.
http-cacheFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
cache makes the app fast without leaking one customer's data to another: keys carry the tenant and region, and credentialed responses are never shared-cacheable.
cache.wrap memoises a call under a key that always carries the current tenant and region, runs it once per key under load, and serves a slightly stale value while it refreshes. Every response to a signed-in person or an API key goes out private, no-store unless you choose a private policy, and shared CDN caching is a declaration on a read action. Small values you set and read yourself belong in kv.
At a glance
cache:
driver: kv
timeout: 50ms
max-value: 256KB
memory: { max-entries: 1000, max-ttl: 30s }import { cache } from "mainsdk/cache";
const top = await cache.wrap("top-products", () => loadTopProducts(), { ttl: "5m", tags: ["products"] });
await cache.delete({ tag: "products" }); // every instance drops itDrivers
2 drivers. Select one to see its line in main.yaml.
cache:
driver: kvChange this one line to swap providers; your calls stay the same.The kv store with an in-process tier integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-CACHE-0011 | Checkmainsdk check, before deploy | cache.scope: public on an action whose risk isn't read, whose method isn't GET, or that declares a permission |
MAIN-CACHE-0012 | Checkmainsdk check, before deploy | An app-scoped cache.wrap or cache.set holds the result of a db read, which is owner or tenant data |
MAIN-CACHE-0001 | RuntimeError responses and logs | See the spec |
MAIN-CACHE-0002 | RuntimeError responses and logs | See the spec |
MAIN-CACHE-0003 | RuntimeError responses and logs | See the spec |
MAIN-CACHE-0004 | RuntimeError responses and logs | See the spec |
MAIN-CACHE-0005 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Private by default on credentialed responses | Phase 1 | 0.4 | http-cache#headers |
| Declared public caching on read actions | Phase 1 | 0.4 | http-cache#public-actions |
| ETags and 304 after the access check | Phase 1 | 0.4 | http-cache#etag |
| Wrap with single flight and stale-while-revalidate | Phase 1 | 0.4 | http-cache#wrap |
| Fail-static reads | Phase 1 | 0.4 | http-cache#fail-static |
| Key and tag invalidation across instances | Phase 1 | 0.4 | http-cache#invalidation |
| No personal data in app-scoped entries; erased with the tenant | Phase 1 | 0.4 | http-cache#privacy |
Quick start
cache:
driver: kv # uses the kv store, with an in-process tier in front
timeout: 50ms # a slower store read is skipped; your function runs insteadimport { cache } from "mainsdk/cache";
declare function loadTopProducts(): Promise<{ id: string }[]>; // your slow query
export function topProducts() {
return cache.wrap("top-products", () => loadTopProducts(), { ttl: "5m", swr: "1h", tags: ["products"] });
}
export function productsChanged() {
return cache.delete({ tag: "products" }); // every instance drops it, in this tenant only
}You should see loadTopProducts run once per tenant however many requests arrive, and run again after productsChanged.
Configuration
cache:
driver: kv # kv (default when the app has a kv store) | local
timeout: 50ms
max-value: 256KB
memory: { max-entries: 1000, max-ttl: 30s }| Key | Default | What it does |
|---|---|---|
driver | kv with a kv store, else local | kv keeps entries in the kv store with an in-process tier in front; local keeps them in this process only |
timeout | 50ms | A store call slower than this is skipped, and your function runs instead |
max-value | 256KB | The largest JSON value stored; a bigger one is not stored (MAIN-CACHE-0003) |
memory.max-entries | 1000 | The most entries the in-process tier holds |
memory.max-ttl | 30s | The longest an entry lives in the in-process tier |
What you get
| Call | What it does |
|---|---|
cache.wrap(key, fn, { ttl, swr?, tags?, scope? }) | The stored value while it is fresh; otherwise one call of fn per key per instance, whatever the number of waiting callers. Between ttl and ttl + swr it returns the old value at once and refreshes it once in the background. |
cache.get(key, { scope? }) | The fresh value, or undefined when there is none or the store did not answer. |
cache.set(key, value, { ttl, tags?, scope? }) | Store a JSON value. |
cache.delete(key | { tag }, { scope? }) | Remove one key or every entry with a tag, in the tenant (or scope: "app"), on every instance. Resolves with the number removed. |
scope is "tenant" by default: the stored key is t:<tenant>:r:<region>:<key>, from the current context. scope: "app" stores app:<key> for every tenant and must hold no personal data: a value with an email, phone number, card number or secret in it is not stored (MAIN-CACHE-0004), and mainsdk check refuses an app-scoped cache around a db read (MAIN-CACHE-0012).
Share a read action with a CDN
Shared caching is a declaration on a read action, never a header you write by hand.
import { action, s } from "mainsdk";
import { serveRead } from "mainsdk/cache";
declare function loadProducts(): Promise<{ id: string }[]>; // your catalogue query
export const listProducts = action({
name: "products.list", intent: "List the public catalogue", risk: "read",
input: s.object({}),
http: { method: "GET", path: "/products", cache: { scope: "public", maxAge: "60s", swr: "5m" } },
run: () => loadProducts(),
});
// in the route handler: return serveRead(listProducts, request, { log });The response carries Cache-Control: public, max-age=60, stale-while-revalidate=300 and a weak ETag; a request with a matching If-None-Match gets 304 after the access check, so a caller without access still gets their 403. If run reads the session, an API key or the tenant, the response goes out private, no-store and the error MAIN-CACHE-0002 is logged. A public action with no permission is open to everyone. scope: "private" with maxAge gives private, max-age=<s> on any read action.
What it always does
- Keys carry the tenant and region; no call can read another tenant's entry (ADR-0073).
- A response to a request with a session cookie, an
Authorizationheader, an API key or a new cookie goes outprivate, no-store, and apublic,s-maxageor baremax-agedirective set by a handler is replaced, itsCDN-Cache-Controlremoved, and the route logged once (MAIN-CACHE-0001). Every adapter applies this after your handler. - A slow or failed cache store never fails or blocks a request (
MAIN-CACHE-0005). - Read actions answer with an
ETagand a304after the access check. - Deleting a key or tag reaches every instance through
events.broadcast; if the broadcast fails, in-process entries expire withincache.memory.max-ttl. - Erasing a tenant deletes every entry under its prefix.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-CACHE-0001 | A credentialed response carried a shared-cache directive; it was replaced with private, no-store (logged once per route) | Declare shared caching on a read action instead of setting the header |
MAIN-CACHE-0002 | A public read action read a session, an API key or the tenant; the response went out private, no-store | Stop reading the credential in run, or make the action private |
MAIN-CACHE-0003 | A key is longer than 256 characters, or a value is not JSON or is over max-value (400); nothing is stored | Use a shorter key or a smaller JSON value |
MAIN-CACHE-0004 | An app-scoped value matched a personal-data detector; it was not stored | Use the default tenant scope, or drop the personal data |
MAIN-CACHE-0005 | The store was slower than timeout or failed; calls are skipped until it answers (logged once per outage) | Restore the store; no restart is needed |
MAIN-CACHE-0011 | mainsdk check: a public cache on an action that is not a GET read, or that declares a permission | Make it a GET read action with no permission, or use scope: "private" |
MAIN-CACHE-0012 | mainsdk check: an app-scoped cache.wrap or cache.set around a db read | Use the tenant scope for owner or tenant data |
Each code's cause and fix is in check codes.
Swapping drivers
Change driver in main.yaml. kv shares entries across instances through the kv store, so a delete reaches all of them. local keeps entries in each process and needs no store. cache.wrap, cache.get, cache.set and cache.delete do not change.
Testing
Build the app with createTestApp() and the local driver, and the cache runs in process with no store. new Cache(opts) takes a store and a clock for the slow-store and expiry cases. See Test your app.
Helpers for wiring and tests
| Call | What it does |
|---|---|
new Cache(opts) | The server cache of one app instance, for tests and custom wiring; apps use cache.wrap. |
parseCache(raw, hasKv?) | Read the cache section of main.yaml into settings. |
cacheIssues(raw) | List what is wrong in the cache section, without throwing. |
etagMatches(header, etag) | True when an If-None-Match header matches an ETag. |
weakEtag(output) | A weak ETag for an action's output. |
Next
Was this page helpful?