MainSDK
Search⌘K
Interfaces

Data

Cache

cache

Memoise 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.

Building for 0.4ships as a beta APIPhase 1 · Core, public betaSpec http-cacheFree in your app, Apache-2.0

Status 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.
Stability levels
Updated 2026-10-28

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 }

Calls

  • cache.wrap(key, fn, { ttl, swr?, tags?, scope? })
  • cache.get(key, { scope? })
  • cache.set(key, value, { ttl, tags?, scope? })
  • cache.delete(key | { tag }, { scope? })
  • new Cache(opts)
  • parseCache(raw, hasKv?)
  • cacheIssues(raw)
  • serveRead(action, req, opts)
  • etagMatches(header, etag)
  • weakEtag(output)

Guarantees

Whatever the driver.

  • Keys carry the tenant and region; no call reads another tenant's entry
  • Personal responses go out private, no-store; shared caching only when declared
  • A slow or failed store never fails or blocks a request
  • One call per key per instance under load

Drivers

2 drivers. Select one to see its line in main.yaml.

main.yamlThe kv store with an in-process tier (default)
cache:
  driver: kv

Change this one line to swap providers; your calls stay the same.The kv store with an in-process tier integration

Codes

CodeWhere it appearsMeaning
MAIN-CACHE-0011Checkmainsdk check, before deploycache.scope: public on an action whose risk isn't read, whose method isn't GET, or that declares a permission
MAIN-CACHE-0012Checkmainsdk check, before deployAn app-scoped cache.wrap or cache.set holds the result of a db read, which is owner or tenant data
MAIN-CACHE-0001RuntimeError responses and logsSee the spec
MAIN-CACHE-0002RuntimeError responses and logsSee the spec
MAIN-CACHE-0003RuntimeError responses and logsSee the spec
MAIN-CACHE-0004RuntimeError responses and logsSee the spec
MAIN-CACHE-0005RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Private by default on credentialed responsesPhase 10.4http-cache#headers
Declared public caching on read actionsPhase 10.4http-cache#public-actions
ETags and 304 after the access checkPhase 10.4http-cache#etag
Wrap with single flight and stale-while-revalidatePhase 10.4http-cache#wrap
Fail-static readsPhase 10.4http-cache#fail-static
Key and tag invalidation across instancesPhase 10.4http-cache#invalidation
No personal data in app-scoped entries; erased with the tenantPhase 10.4http-cache#privacy

Quick start

main.yaml
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 instead
lib/products.ts
import { 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

main.yaml
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 }
KeyDefaultWhat it does
driverkv with a kv store, else localkv keeps entries in the kv store with an in-process tier in front; local keeps them in this process only
timeout50msA store call slower than this is skipped, and your function runs instead
max-value256KBThe largest JSON value stored; a bigger one is not stored (MAIN-CACHE-0003)
memory.max-entries1000The most entries the in-process tier holds
memory.max-ttl30sThe longest an entry lives in the in-process tier

What you get

CallWhat 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.

actions/products.ts
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 Authorization header, an API key or a new cookie goes out private, no-store, and a public, s-maxage or bare max-age directive set by a handler is replaced, its CDN-Cache-Control removed, 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 ETag and a 304 after the access check.
  • Deleting a key or tag reaches every instance through events.broadcast; if the broadcast fails, in-process entries expire within cache.memory.max-ttl.
  • Erasing a tenant deletes every entry under its prefix.

Errors and checks

CodeWhenFix
MAIN-CACHE-0001A 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-0002A public read action read a session, an API key or the tenant; the response went out private, no-storeStop reading the credential in run, or make the action private
MAIN-CACHE-0003A key is longer than 256 characters, or a value is not JSON or is over max-value (400); nothing is storedUse a shorter key or a smaller JSON value
MAIN-CACHE-0004An app-scoped value matched a personal-data detector; it was not storedUse the default tenant scope, or drop the personal data
MAIN-CACHE-0005The 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-0011mainsdk check: a public cache on an action that is not a GET read, or that declares a permissionMake it a GET read action with no permission, or use scope: "private"
MAIN-CACHE-0012mainsdk check: an app-scoped cache.wrap or cache.set around a db readUse 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

CallWhat 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?

↑↓ moveEnter openEsc close

Not decided yet