Query caching

Opt a query into server-side snapshot caching with the cache field — automatic table-tag invalidation and the required scope security rule (subject vs global).

Add a cache field to a defineQuery and the framework caches the query's server snapshot and auto-invalidates it when a mutation writes any table the query depends on. No manual busting, no glue.

// queries/todos.listByTenant.query.ts
export const listTodos = defineQuery({
  name:   'todos.listByTenant',
  source: 'todos',
  input:  Schema.Struct({ done: Schema.optional(Schema.Boolean) }),
  output: Todo,
  cache:  { ttl: '30s', swr: '5m', scope: 'subject' },   // tenant-filtered → subject
})

The matching .<primitive>.server.ts is unchanged — caching is a descriptor concern. ttl / swr accept seconds (30) or a duration string ('30s', '5m', '1h').

scope is required — and it's a security decision

scope has no default, because guessing wrong leaks one user's rows to another. The rubric is one question:

Does the resolved query depend on the caller?

  • Yes — tenant-filtered, where authorId = me, anything row-scoped → scope: 'subject'. The cache key includes the caller's subject id, so two subjects can never share an entry.
  • No — the same rows for everyone (reference / lookup data) → scope: 'global'. One entry shared across all callers.
// reference data — identical for everyone → global
export const listCountries = defineQuery({
  name:   'reference.countries',
  source: 'countries',
  input:  Schema.Void,
  output: Country,
  cache:  { ttl: '1h', scope: 'global' },
})

Never put scope: 'global' on a subject-filtered query. Tenant tables are auto-scoped per subject by the runtime, so a global cache over one would serve tenant A's rows to tenant B. When in doubt, use subject.

How auto-invalidation works

When you opt in, the framework tags the cached snapshot with the full set of tables the query reads — the root source plus every table reached through eager .with(...) relations and joins. A mutation that writes any of those tables drops the entry through the same invalidation bus the low-level wrap uses. The writer's own next read recomputes (read-your-writes holds).

  • CDC=1 (postgres LISTEN/NOTIFY) → invalidation propagates across instances.
  • CDC=0 → single-process invalidation only. Fine for dev; for multi-instance global caches you want CDC on.

When to use it vs. a live subscription

Live useSubscription queries already stay fresh by pushing deltas — they don't need this. Query caching earns its keep for the initial snapshot shared across many subscribers/instances (cutting redundant DB hits when N tabs/pods open the same query) and for adding an SWR window. If a query is opened once and rarely, the live engine alone is enough; reach for cache: on hot, widely-shared read paths.

Inspect hit-rate live in the dashboard's Cache panel, or via voltro cache status — see Enabling Redis.