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