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',
  guards: [{ scope: 'todos:read' }],                     // who may call it
  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, with three answers:

Does the resolved query depend on the caller — and on what about them?

  • On the PERSONwhere authorId = me, anything row-scoped → scope: 'subject'. The cache key includes the caller's subject id, so two subjects can never share an entry.
  • On their ORG only — an org-wide figure every colleague sees identically → scope: 'tenant'. One entry per tenantId, never shared across orgs.
  • On neither — 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',
  openAccess: 'a static country reference list — the same rows for every caller, '
    + 'no tenant rows and nothing caller-derived',
  input:  Schema.Void,
  output: Country,
  cache:  { ttl: '1h', scope: 'global' },
})

// an org-wide statistic — same for all 18 colleagues, never across orgs
export const last12Months = defineQuery({
  name:   'globalStatistics.last12Months',
  source: ['invoices', 'employees'],
  guards: [{ scope: 'analytics:read' }],
  input:  Schema.Struct({}),
  output: Stats,
  cache:  { ttl: '5m', scope: 'tenant' },
})

cache.scope and the access decision are two questions, and they line up here by coincidence rather than by rule. Every wire-exposed query must also declare guards:, openAccess: '<reason>' or internal: true or the boot refuses it — and the reasoning that made scope: 'global' correct for reference.countries (the same rows for everyone, nothing caller-derived) is the same reasoning that makes openAccess honest there. It does not generalise: a query can be perfectly cacheable per subject and need a scope to call, which is todos.listByTenant above. Decide them separately; see Authorization.

'tenant' exists because the other two were the only options and neither fit an org-wide figure: 'subject' recomputes it per person — eighteen identical computations of the same nine-table statistic for an eighteen-person org — and 'global' shares one entry across tenant boundaries, which for data derived from subject.tenantId is not a cache but a leak.

A caller with no tenantId (an anonymous or system subject) bypasses a 'tenant' cache rather than sharing a null-keyed entry.

Never put scope: 'global' on a subject- or tenant-filtered query. Tenant tables are auto-scoped by the runtime, so a global cache over one would serve tenant A's rows to tenant B. The boot audit checks this: a 'global' scope over a tenant()-scoped table is reported by voltro dev and refused under VOLTRO_SERVER_ONLY=strict. It stays silent for 'global' on reference data — the case the option exists for — and for a query with no declared source, where it has nothing to reason about.

scope: 'tenant' is not a replacement for modelling. For a rollup, an aggregate with tenantId as an indexed column puts the tenant boundary in the data rather than in a cache key, which is better. 'tenant' is for the other case: a query that must be FRESH and is merely expensive, where an aggregate's refresh interval is the wrong instrument.

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.