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

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 add @baeta/cache

Storage adapters

The default choice for most production deployments.

yarn add @baeta/cache-ioredis ioredis
src/lib/redis.ts
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 add @baeta/cache-iovalkey iovalkey
src/lib/valkey.ts
import { ValkeyCacheClient } from "@baeta/cache-iovalkey";
import Valkey from "iovalkey";

const valkey = new Valkey("valkey://localhost:6379");

export const valkeyClient = new ValkeyCacheClient(valkey);

HTTP-based Redis client — works in Workers, Lambda, and other environments where TCP connections are awkward.

yarn add @baeta/cache-upstash @upstash/redis
src/lib/upstash.ts
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 add @baeta/cache-cloudflare

Basic setup

1. Define a cache with queries

src/modules/user/user.cache.ts
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();
tip

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.

tip

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/delete over manual invalidation. They give the cache enough information to reconcile related queries; bare invalidation drops more than necessary.
  • Use indexArgsBy for relationship queries. It scopes invalidation to the affected index keys instead of flushing the whole query.
  • Bump revision whenever the cached value shape changes — instant invalidation without touching the store.

For a runnable end-to-end setup, see the caching example.