VibORM

Caching

Built-in caching for read queries with automatic invalidation and stale-while-revalidate support

Features

  • Multiple Backends - In-memory, Cloudflare KV, or custom implementations
  • Automatic Invalidation - Optionally clear cache when data changes
  • Stale-While-Revalidate - Return stale data instantly while refreshing in background
  • Serverless Ready - Works with Cloudflare Workers and Vercel Edge
  • Type-Safe - Full TypeScript support, only read operations available on cached client
  • Cache Versioning - Invalidate all cache on schema changes
  • OpenTelemetry - Built-in tracing with hit/miss/stale status

Quick Start

import { createClient, MemoryCache } from "viborm";

const client = createClient({
  schema: { user, post },
  driver,
  cache: new MemoryCache(),
});

// Use cached queries
const users = await client.$withCache().user.findMany({
  where: { active: true },
});

Configuration

Setting Up a Cache Driver

Pass a cache driver when creating the client:

import { MemoryCache, CloudflareKVCache } from "viborm";

// In-memory cache (development/single instance)
const client = createClient({
  schema,
  driver,
  cache: new MemoryCache(),
});

// Cloudflare KV cache (production/distributed)
const client = createClient({
  schema,
  driver,
  cache: new CloudflareKVCache(env.MY_KV_NAMESPACE),
});

Cache Versioning

Use cacheVersion to invalidate all cached data when your schema changes:

const client = createClient({
  schema,
  driver,
  cache: new MemoryCache(),
  cacheVersion: 2,  // Bump when schema changes
});

Cache keys become versioned: viborm:v2:user:findMany:...

When you update the version, all previously cached entries are automatically ignored (different key prefix). This prevents stale data with incompatible shapes from being returned after schema migrations.

Using $withCache

The $withCache() method returns a cached client that only exposes read operations:

// Default: 5 minute TTL
const cached = client.$withCache();

// Custom TTL
const cached = client.$withCache({ ttl: "1 hour" });
const cached = client.$withCache({ ttl: 30000 }); // 30 seconds in ms

// With stale-while-revalidate
const cached = client.$withCache({ ttl: "5 minutes", swr: true });

// Force fresh fetch (bypass cache read)
const fresh = client.$withCache({ bypass: true });

Available Options

OptionTypeDefaultDescription
ttlstring | number300000 (5 min)Cache duration. String format: "1 hour", "30 seconds", etc.
swrboolean | string | numberfalseEnable stale-while-revalidate. When true, uses 2x TTL. Can also specify a custom SWR window duration.
keystringauto-generatedCustom cache key (overrides automatic key generation)
bypassbooleanfalseSkip cache read and force fresh fetch (still writes to cache)

TTL Format

TTL can be specified as milliseconds or human-readable strings:

// Milliseconds
{ ttl: 60000 }  // 1 minute

// Human-readable strings
{ ttl: "30 seconds" }
{ ttl: "5 minutes" }
{ ttl: "1 hour" }
{ ttl: "2 hours" }
{ ttl: "1 day" }
{ ttl: "1 week" }

Supported units: ms, s/sec/second/seconds, m/min/minute/minutes, h/hr/hour/hours, d/day/days, w/week/weeks, month/months

Cached Operations

Only read operations are available on the cached client:

const cached = client.$withCache({ ttl: "1 hour" });

// Available operations
await cached.user.findMany({ where: { active: true } });
await cached.user.findFirst({ where: { role: "admin" } });
await cached.user.findUnique({ where: { id: "123" } });
await cached.user.findUniqueOrThrow({ where: { id: "123" } });
await cached.user.findFirstOrThrow({ where: { role: "admin" } });
await cached.user.count({ where: { active: true } });
await cached.user.aggregate({ _avg: { age: true } });
await cached.user.groupBy({ by: ["role"], _count: true });
await cached.user.exist({ where: { email: "test@example.com" } });

// Mutation operations are NOT available on cached client
// cached.user.create() // TypeScript error!
// cached.user.update() // TypeScript error!

Stale-While-Revalidate (SWR)

SWR improves perceived performance by returning cached data immediately while refreshing in the background:

const cached = client.$withCache({ ttl: "5 minutes", swr: true });

// First request: cache miss, executes query
const users1 = await cached.user.findMany(); // ~50ms (database query)

// Second request (within TTL): fresh cache hit
const users2 = await cached.user.findMany(); // ~1ms (from cache)

// Third request (after TTL but within 2x TTL): stale hit + background refresh
const users3 = await cached.user.findMany(); // ~1ms (stale data returned)
// Background: query executes and updates cache

How SWR Works

  1. Fresh hit (age < TTL): Return cached data immediately
  2. Stale hit (TTL < age < SWR window): Return stale data, refresh in background
  3. Miss (age > SWR window or not cached): Execute query, cache result

By default, the SWR window is 2× the TTL. You can customize this:

// Default: SWR window is 2x TTL (10 minutes total storage)
const cached = client.$withCache({ ttl: "5 minutes", swr: true });

// Custom SWR window of 1 hour (stored for 1 hour total)
const cached = client.$withCache({ ttl: "5 minutes", swr: "1 hour" });

// Custom SWR window in milliseconds
const cached = client.$withCache({ ttl: 300000, swr: 3600000 });

Serverless Environments

In serverless environments (Cloudflare Workers, Vercel Edge), configure waitUntil at the client level to ensure background revalidation completes:

// Cloudflare Workers
export default {
  async fetch(request, env, ctx) {
    const client = createClient({
      schema: { user },
      driver: createD1Driver(env.DB),
      cache: new CloudflareKVCache(env.CACHE),
      waitUntil: ctx.waitUntil.bind(ctx),
    });

    const users = await client
      .$withCache({ ttl: "5 minutes", swr: true })
      .user.findMany();

    return Response.json(users);
  }
}

// Vercel Edge
import { waitUntil } from "@vercel/functions";

const client = createClient({
  schema: { user },
  driver,
  cache: new MemoryCache(),
  waitUntil,
});

export async function GET() {
  const users = await client
    .$withCache({ ttl: "5 minutes", swr: true })
    .user.findMany();

  return Response.json(users);
}

Without waitUntil in serverless environments, background revalidation may be terminated when the request completes.

Cache Invalidation

Direct Invalidation with $invalidate

Use $invalidate to manually invalidate cache entries at any time:

// Invalidate specific keys
await client.$invalidate("user:findMany:abc123");

// Invalidate by prefix (use * suffix)
await client.$invalidate("user:*");

// Invalidate multiple patterns
await client.$invalidate("user:*", "post:findMany:*");

When cacheVersion is configured, $invalidate automatically uses the versioned prefix. For example, with cacheVersion: 2, calling $invalidate("user:*") clears viborm:v2:user:*.

Invalidation on Mutations

Specify cache keys or prefixes to invalidate when performing mutations:

// Invalidate specific keys
await client.user.update({
  where: { id: "123" },
  data: { name: "Alice" },
  cache: {
    invalidate: ["user:findUnique:abc123"],
  },
});

// Invalidate by prefix (note the * suffix)
await client.user.update({
  where: { id: "123" },
  data: { name: "Alice" },
  cache: {
    invalidate: ["user:findMany:*"],  // Clears all findMany caches for user
  },
});

// Invalidate multiple patterns
await client.post.create({
  data: { title: "New Post", authorId: "123" },
  cache: {
    invalidate: [
      "post:*",      // All post caches
      "user:123:*",  // All caches for this user
    ],
  },
});

Automatic Model Invalidation

Enable autoInvalidate to automatically clear all cache entries for a model after mutations:

await client.user.update({
  where: { id: "123" },
  data: { name: "Alice" },
  cache: {
    autoInvalidate: true,  // Clears all user:* cache entries
  },
});

Auto-invalidation clears ALL cached queries for the model. Use manual invalidation for more granular control.

Cache Key Generation

Cache keys are automatically generated from the model name, operation, and query arguments:

viborm[:v<version>]:<model>:<operation>:<hash>

For example:

viborm:user:findMany:a1b2c3d4e5f6...
viborm:v2:user:findMany:a1b2c3d4e5f6...  // with cacheVersion: 2

The hash is deterministic — identical queries always produce the same key, regardless of object property order.

The current key format includes the operation name, which means findFirst and findUnique with identical arguments will have separate cache entries even though they may produce the same SQL query. Use custom cache keys if you need to share cache entries across operations.

Custom Cache Keys

Override automatic key generation when needed:

const cached = client.$withCache({
  ttl: "1 hour",
  key: "homepage-featured-users",
});

const users = await cached.user.findMany({
  where: { featured: true },
  take: 5,
});

Generating Keys for Manual Invalidation

Use generateCacheKey to create keys matching the auto-generated format:

import { generateCacheKey } from "viborm";

// Generate the same key that would be used for a cached query
const key = generateCacheKey(
  "user",
  "findMany",
  { where: { active: true } },
  2  // optional: cacheVersion
);
// "viborm:v2:user:findMany:abc123..."

// Use for precise invalidation
await client.$invalidate(key);

Observability

Cache operations emit OpenTelemetry spans when instrumentation is configured.

Spans

Span NameDescription
viborm.cache.getCache read operation
viborm.cache.setCache write operation
viborm.cache.deleteCache key deletion
viborm.cache.clearCache prefix clearing
viborm.cache.invalidateCache invalidation

Attributes

AttributeDescription
cache.driverDriver name (memory, cloudflare-kv, etc.)
cache.keyCache key being accessed
cache.resultResult of cache read: hit, miss, stale, or bypass
cache.ttlTTL in milliseconds

Example Trace

viborm.operation (user.findMany)
└── viborm.cache.get (cache.result: "hit")

When SWR returns stale data:

viborm.operation (user.findMany)
└── viborm.cache.get (cache.result: "stale")
    └── [background] viborm.cache.set

Cache Drivers

VibORM includes built-in cache drivers and supports custom implementations.

DriverBest ForPersistence
MemoryCacheDevelopment, testingNo
CloudflareKVCacheCloudflare WorkersYes
CustomRedis, Upstash, etc.Varies

See Cache Drivers for detailed configuration and implementation guides.

Examples

Basic Caching

const client = createClient({
  schema: { user, post },
  driver,
  cache: new MemoryCache(),
});

// Cache user queries for 10 minutes
const cached = client.$withCache({ ttl: "10 minutes" });

const users = await cached.user.findMany({
  where: { active: true },
  include: { posts: true },
});

SWR with Cloudflare Workers

import { createClient, CloudflareKVCache } from "viborm";

export default {
  async fetch(request, env, ctx) {
    const client = createClient({
      schema: { user },
      driver: createD1Driver(env.DB),
      cache: new CloudflareKVCache(env.CACHE),
      waitUntil: ctx.waitUntil.bind(ctx),
    });

    const users = await client
      .$withCache({ ttl: "5 minutes", swr: true })
      .user.findMany({ where: { active: true } });

    return Response.json(users);
  }
}

Invalidation on Write

// Create a post and invalidate related caches
await client.post.create({
  data: {
    title: "New Post",
    authorId: userId,
  },
  cache: {
    invalidate: [
      "post:findMany:*",           // All post lists
      `user:${userId}:posts:*`,    // This user's post caches
    ],
  },
});

Different TTLs for Different Queries

// Short TTL for frequently changing data
const recentPosts = await client
  .$withCache({ ttl: "1 minute" })
  .post.findMany({
    orderBy: { createdAt: "desc" },
    take: 10,
  });

// Longer TTL for stable data
const categories = await client
  .$withCache({ ttl: "1 hour" })
  .category.findMany();

// SWR for user-facing pages
const featuredUsers = await client
  .$withCache({ ttl: "5 minutes", swr: true })
  .user.findMany({
    where: { featured: true },
  });

On this page