Skip to main content
Version: Next (2.x)

Caching

Baeta provides a powerful and flexible caching system with support for multiple storage adapters. The caching system offers declarative query definitions, automatic cache reconciliation on insert/update/delete, and type-safe cache operations.

Key Features

  • Type-safe cache operations
  • Declarative query definitions with defineQuery
  • Automatic cache reconciliation on insert/update/delete
  • Index-based query invalidation
  • Custom serialization/deserialization
  • Multiple storage adapters
  • TTL support

Installation

yarn add @baeta/extension-cache @baeta/cache

Storage Adapters

Baeta supports several storage adapters for different use cases:

Best for production environments with high query volumes.

yarn add @baeta/cache-ioredis ioredis
import { RedisCacheClient } from "@baeta/cache-ioredis";
import Redis from "ioredis";

const redis = new Redis("redis://localhost:6379");
const client = new RedisCacheClient(redis);

Optimized for serverless environments.

yarn add @baeta/cache-upstash @upstash/redis
import { UpstashCacheClient } from "@baeta/cache-upstash";

const client = new UpstashCacheClient({
url: "UPSTASH_REDIS_URL",
token: "UPSTASH_REDIS_TOKEN",
});

Cloudflare

For Cloudflare Workers environments.

yarn add @baeta/cache-cloudflare

Basic Setup

  1. Configure Cache Extension
import { cacheExtension } from "@baeta/extension-cache";
import { RedisCacheClient } from "@baeta/cache-ioredis";
import Redis from "ioredis";

const redis = new Redis("redis://localhost:6379");
const redisClient = new RedisCacheClient(redis);

export const cacheExt = cacheExtension(redisClient);
  1. Register the Extension

Create src/modules/extensions.ts:

import { createExtensions } from "@baeta/core";
import { cacheExt } from "../extensions/cache-extension.ts";

export default createExtensions({
cacheExtension: cacheExt,
//... other extensions
});
  1. Create Type-Specific Cache with Queries
import { defineQuery } from "@baeta/cache";
import { UserModule } from "./typedef.ts";

const { User, Query } = UserModule;

export const userCache = User.$createCache({
// Custom serialization (optional)
parse: (value) => JSON.parse(value),
serialize: (value) => JSON.stringify(value),
})
.withQueries({
findUser: defineQuery({
resolve: async (args: { id?: string | null; email?: string | null }) => {
return db.user.findFirst({
where: {
id: args.id ?? undefined,
email: args.email ?? undefined,
},
});
},
}),
findUsers: defineQuery({
resolve: async (args: {}) => {
return db.user.findMany();
},
}),
})
.build();
tip

When you modify type fields, the store will be automatically invalidated (its hash changes).

Caching Examples

Basic Query Caching

Use $resolveCache to connect a query field to a cached query definition. The second argument maps the resolver's parameters to the query arguments:

const { Query } = UserModule;

export default Query.$fields({
user: Query.user.$resolveCache(userCache.queries.findUser, ({ args }) => ({
id: args.where.id,
email: args.where.email,
})),
users: Query.users
.$resolveCache(userCache.queries.findUsers, () => ({}))
.map(({ source }) => source ?? []),
});

Mutation Handling

Use update for existing items and insert for new items. The cache will automatically reconcile all related queries:

const createUserMutation = Mutation.createUser.resolve(async ({ args }) => {
const user = await db.user.create({ data: args.data });
// Use "insert" for new items, so cache queries can reconcile
await userCache.insert(user);
return user;
});

const updateUserMutation = Mutation.updateUser
.$use(async (next) => {
const user = await next();
if (user) {
// Use "update" for existing items — automatically updates all queries
await userCache.update(user);
}
return user;
})
.resolve(async ({ args }) => {
return db.user.update({
where: { id: args.where.id },
data: args.data,
});
});

Index-Based Query Invalidation

For relationship queries, use indexArgsBy to enable targeted invalidation. When items are inserted or deleted, only queries matching the relevant index values are invalidated:

import { defineQuery } from "@baeta/cache";

const userPhotoCache = UserPhoto.$createCache({
parse: (value) => JSON.parse(value),
serialize: (value) => JSON.stringify(value),
})
.withQueries({
findUserPhotos: defineQuery({
resolve: async (args: { userId: string }) => {
return db.userPhoto.findMany({
where: { userId: args.userId },
});
},
// Index queries by userId for targeted invalidation
indexArgsBy: {
userId: true,
},
// On insert, invalidate queries matching the new item's userId
onInsert(items, helpers) {
const args = items.map((item) => ({ userId: item.userId }));
return helpers.invalidateByArgs(args);
},
// On delete, invalidate queries matching the deleted item's userId
onDelete(pairs, helpers) {
const args = pairs
.map((item) => item.previous && { userId: item.previous.userId })
.filter((el) => el != null);
return helpers.invalidateByArgs(args);
},
}),
})
.build();

Then use it in resolvers:

const userPhotosResolver = User.photos
.$resolveCache(userPhotoCache.queries.findUserPhotos, ({ source }) => ({
userId: source.id,
}))
.withDefault([]);

Best Practices

  1. Choose the Right Adapter
  • Use Redis for production environments
  • Use Upstash for serverless applications
  1. Cache Invalidation Strategy
  • Use indexArgsBy for targeted invalidation of relationship queries
  • Implement onInsert/onDelete hooks for automatic query invalidation
  • Use insert for new items and update for existing items
  1. Performance Optimization
  • Configure Redis to evacuate least used keys
  • Use index-based invalidation over full query clearing when possible

For detailed examples, see the Baeta caching example.