Query Caching
Cache query-builder execute() results with tags, TTLs, and custom providers.
Query Caching
Query caching belongs to the typed ClickHouse builder in @hypequery/clickhouse. It caches the result of execute() so repeated reads do not always hit ClickHouse.
Configure a cache provider
Start by enabling caching on the db client:
import { createQueryBuilder, MemoryCacheProvider } from '@hypequery/clickhouse'; const db = createQueryBuilder({ host: process.env.CLICKHOUSE_HOST!, cache: { mode: 'stale-while-revalidate', ttlMs: 2_000, staleTtlMs: 30_000, staleIfError: true, provider: new MemoryCacheProvider({ maxEntries: 1_000 }), }, });
Cache an individual query
Use .cache(...) on the builder chain:
const rows = await db .table('orders') .sum('total', 'revenue') .groupBy(['customer_id']) .orderBy('revenue', 'DESC') .limit(10) .cache({ tags: ['orders'], ttlMs: 5_000 }) .execute();
Use .cache(...) when the query should normally be cached everywhere it is used.
For one-off overrides, pass cache options directly to execute(...):
const rows = await db .table('orders') .sum('total', 'revenue') .groupBy(['customer_id']) .execute({ cache: { mode: 'network-first', ttlMs: 1_000, tags: ['orders', 'dashboards'], }, });
Cache modes
| Mode | Description |
|---|---|
cache-first | Serve hot entries, otherwise fetch and store. |
network-first | Always hit ClickHouse; fall back to stale data when staleIfError is enabled. |
stale-while-revalidate | Serve stale-but-acceptable results immediately and refresh in the background. |
no-store | Skip caching entirely. |
Recommended defaults:
- Use
cache-firstfor dashboards and read-heavy widgets where slightly stale data is fine. - Use
network-firstwhen freshness matters more than latency, but you still want stale fallback during ClickHouse failures. - Use
stale-while-revalidatewhen you want fast responses for repeated reads without blocking on a refresh. - Use
no-storeto bypass caching for highly volatile or user-specific reads.
Cache options
| Option | What it does |
|---|---|
ttlMs | How long an entry is considered fresh. |
staleTtlMs | Extra time an entry may still be served as stale. |
cacheTimeMs | Total time the provider should retain the entry. Defaults to ttlMs + staleTtlMs. |
staleIfError | In network-first, serve stale data if ClickHouse fails and a stale entry is available. |
dedupe | Reuse in-flight fetches for the same cache key instead of sending duplicate queries. |
tags | Attach tags for later invalidation. |
key | Override the generated cache key when you want multiple query shapes to share one entry. |
namespace | Isolate entries from other cache consumers sharing the same provider. |
serialize / deserialize | Customize how cached results are encoded and decoded before storage. |
By default, hypequery generates deterministic cache keys from the SQL, parameters, and settings for the query.
Invalidate and inspect cache state
await db.cache.invalidateKey('hq:v1:analytics:orders:abc123'); await db.cache.invalidateTags(['orders', 'dashboards']); await db.cache.clear(); await db.cache.warm([ () => db.table('orders').select(['id']).cache({ tags: ['orders'] }).execute(), () => db.table('users').select(['id']).cache({ tags: ['users'] }).execute(), ]); const stats = db.cache.getStats(); console.log(stats.hitRate, stats.hits, stats.misses, stats.staleHits, stats.revalidations);
invalidateTags(...) requires the active cache provider to implement deleteByTag(...). If it does not, hypequery will warn and only clear its in-memory parsed values for matching tags.
Bring your own provider
Use a custom CacheProvider when you want Redis, Upstash, KV, or another shared store:
import type { CacheEntry, CacheProvider } from '@hypequery/clickhouse'; import { Redis } from 'ioredis'; class RedisCacheProvider implements CacheProvider<string> { constructor(private readonly client = new Redis(process.env.REDIS_URL!)) {} async get(key: string) { const raw = await this.client.get(key); return raw ? (JSON.parse(raw) as CacheEntry) : null; } async set(key: string, entry: CacheEntry) { await this.client.set(key, JSON.stringify(entry), 'PX', entry.cacheTimeMs ?? entry.ttlMs); } async delete(key: string) { await this.client.del(key); } async deleteByTag(namespace: string, tag: string) { const tagKey = `hq:tag:${namespace}:${tag}`; const keys = await this.client.smembers(tagKey); if (keys.length) await this.client.del(...keys); await this.client.del(tagKey); } }