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 PERSON —
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. - On their ORG only — an org-wide figure every colleague sees identically →
scope: 'tenant'. One entry pertenantId, 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-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.