Caching
@baeta/cache is a typed cache layer for resolvers. You declare each query with defineQuery, point the cache at a storage adapter (Redis, Valkey, Upstash, Cloudflare), and the cache handles serialization, key derivation, and invalidation when you mutate the underlying data.
At a glance
- Type-safe cache operations (
get,update,insert,delete, per-query helpers). - Declarative query definitions via
defineQuery. - Automatic reconciliation across queries on insert/update/delete.
- Index-based invalidation for relationship queries.
- Custom serialization (Zod, JSON, anything).
- Pluggable storage with TTL support.
Installation
- yarn
- npm
- pnpm
- bun
yarn add @baeta/cache
npm install @baeta/cache
pnpm add @baeta/cache
bun add @baeta/cache
Storage adapters
Redis (recommended)
The default choice for most production deployments.
- yarn
- npm
- pnpm
- bun
yarn add @baeta/cache-ioredis ioredis
npm install @baeta/cache-ioredis ioredis
pnpm add @baeta/cache-ioredis ioredis
bun add @baeta/cache-ioredis ioredis
import { RedisCacheClient } from "@baeta/cache-ioredis";
import Redis from "ioredis";
const redis = new Redis("redis://localhost:6379");
export const redisClient = new RedisCacheClient(redis);
Valkey
Drop-in alternative for projects on Valkey — the open-source Redis fork — using the iovalkey client.
- yarn
- npm
- pnpm
- bun
yarn add @baeta/cache-iovalkey iovalkey
npm install @baeta/cache-iovalkey iovalkey
pnpm add @baeta/cache-iovalkey iovalkey
bun add @baeta/cache-iovalkey iovalkey
import { ValkeyCacheClient } from "@baeta/cache-iovalkey";
import Valkey from "iovalkey";
const valkey = new Valkey("valkey://localhost:6379");
export const valkeyClient = new ValkeyCacheClient(valkey);
Upstash (recommended for serverless)
HTTP-based Redis client — works in Workers, Lambda, and other environments where TCP connections are awkward.
- yarn
- npm
- pnpm
- bun
yarn add @baeta/cache-upstash @upstash/redis
npm install @baeta/cache-upstash @upstash/redis
pnpm add @baeta/cache-upstash @upstash/redis
bun add @baeta/cache-upstash @upstash/redis
import { UpstashCacheClient } from "@baeta/cache-upstash";
export const upstashClient = new UpstashCacheClient({
url: "UPSTASH_REDIS_URL",
token: "UPSTASH_REDIS_TOKEN",
});
Cloudflare
Durable-Objects-backed cache for Cloudflare Workers.
- yarn
- npm
- pnpm
- bun
yarn add @baeta/cache-cloudflare
npm install @baeta/cache-cloudflare
pnpm add @baeta/cache-cloudflare
bun add @baeta/cache-cloudflare
Basic setup
1. Define a cache with queries
import { createCache, defineQuery } from "@baeta/cache";
import { z } from "zod";
import { redisClient } from "../../lib/redis.ts";
import { db } from "../../lib/db.ts";
const UserCacheSchema = z.object({
id: z.string(),
email: z.string(),
lastName: z.string(),
givenName: z.string().nullable(),
});
const findUser = defineQuery({
resolve: async (args: { id: string | null; email: string | null }) => {
return db.user.findUnique({
where: {
id: args.id ?? undefined,
email: args.email ?? undefined,
},
});
},
});
const findUsers = defineQuery({
resolve: async () => {
return db.user.findMany();
},
});
export const userCache = createCache(redisClient, {
name: "UserCache",
// Bump whenever the cached value shape changes — invalidates all entries.
revision: 1,
// Optional: time-to-live per entry. Defaults to 1 hour.
ttlMs: 60 * 60 * 1000,
// Custom serialization. The real example uses Zod to validate on the
// way out so corrupted entries surface immediately.
parse: (value) => UserCacheSchema.parse(JSON.parse(value)),
serialize: (value) => JSON.stringify(value),
})
.withQueries({
findUser,
findUsers,
})
.build();
revision is a fast invalidation switch — bumping it makes every previously cached entry a miss without touching the store. Use it whenever you change the shape (fields, types) of cached values; relying on parse alone to catch shape drift will cause runtime errors on stale entries.
ttlMs defaults to 3_600_000 (1 hour). You can also set a default on the storage client itself — per-cache ttlMs overrides the client default.
Patterns
Reading through the cache
.map() routes a resolver through a cached query — the mapper turns resolver params into the query arguments:
import { UserModule } from "./typedef.ts";
import { userCache } from "./user.cache.ts";
const { Query } = UserModule;
export default Query.$fields({
user: Query.user.map(({ args }) =>
userCache.queries.findUser({
id: args.where.id,
email: args.where.email,
}),
),
users: Query.users
.map(() => userCache.queries.findUsers({}))
.map(({ source }) => source ?? []),
});
Reconciling on mutation
Use update for existing items and insert for new ones. The cache reconciles every related query automatically:
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 invalidation
For relationship queries — "all photos for user X" — declare indexArgsBy to make invalidation surgical. Only the queries whose index matches the changed items are dropped:
import { createCache, defineQuery } from "@baeta/cache";
export const userPhotoCache = createCache(redisClient, {
name: "UserPhotoCache",
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
.map(({ source }) =>
userPhotoCache.queries.findUserPhotos({
userId: source.id,
}),
)
.withDefault([]);
Best practices
- Pick the adapter that matches your runtime. Redis or Valkey for long-lived Node processes; Upstash for serverless; Cloudflare for Workers.
- Prefer
insert/update/deleteover manual invalidation. They give the cache enough information to reconcile related queries; bare invalidation drops more than necessary. - Use
indexArgsByfor relationship queries. It scopes invalidation to the affected index keys instead of flushing the whole query. - Bump
revisionwhenever the cached value shape changes — instant invalidation without touching the store.
For a runnable end-to-end setup, see the caching example.