Skip to content

Commit e475b54

Browse files
Cache (#548)
* Add cache docs in advanced * Cache docs changes * Add cache config * review cache docs * Add last changes to cache docs * Add limitations section * Add callout --------- Co-authored-by: Josh <[email protected]>
1 parent 434d36e commit e475b54

File tree

2 files changed

+353
-0
lines changed

2 files changed

+353
-0
lines changed

src/content/docs/_meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
["generated-columns", "Generated Columns"],
9898
["transactions", "Transactions"],
9999
["batch-api", "Batch"],
100+
["cache", "Cache"],
100101
["dynamic-query-building", "Dynamic query building"],
101102
["read-replicas", "Read Replicas"],
102103
["custom-types", "Custom types"],

src/content/docs/cache.mdx

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
import Callout from '@mdx/Callout.astro';
2+
import Npm from '@mdx/Npm.astro';
3+
4+
# Cache
5+
6+
<Callout type='warning'>
7+
Available only on `drizzle-orm@cache` tag
8+
9+
<Npm>
10+
drizzle-orm@cache
11+
</Npm>
12+
</Callout>
13+
14+
Drizzle sends every query straight to your database by default. There are no hidden actions, no automatic caching
15+
or invalidation - you'll always see exactly what runs. If you want caching, you must opt in.
16+
17+
By default, Drizzle uses a `explicit` caching strategy (i.e. `global: false`), so nothing is ever cached unless you ask.
18+
This prevents surprises or hidden performance traps in your application.
19+
Alternatively, you can flip on `all` caching (`global: true`) so that every select will look in cache first.
20+
21+
## Quickstart
22+
23+
### Upstash integration
24+
25+
Drizzle provides an `upstashCache()` helper out of the box. By default, this uses Upstash Redis with automatic configuration if environment variables are set.
26+
27+
```ts
28+
import { upstashCache } from "drizzle-orm/cache/upstash";
29+
import { drizzle } from "drizzle-orm/...";
30+
31+
const db = drizzle(process.env.DB_URL!, {
32+
cache: upstashCache(),
33+
});
34+
```
35+
36+
You can also explicitly define your Upstash credentials, enable global caching for all queries by default or pass custom caching options:
37+
38+
```ts
39+
import { upstashCache } from "drizzle-orm/cache/upstash";
40+
import { drizzle } from "drizzle-orm/...";
41+
42+
const db = drizzle(process.env.DB_URL!, {
43+
cache: upstashCache({
44+
// 👇 Redis credentials (optional — can also be pulled from env vars)
45+
url: '<UPSTASH_URL>',
46+
token: '<UPSTASH_TOKEN>',
47+
48+
// 👇 Enable caching for all queries by default (optional)
49+
global: true,
50+
51+
// 👇 Default cache behavior (optional)
52+
config: { ex: 60 }
53+
})
54+
});
55+
```
56+
57+
## Cache config reference
58+
59+
Drizzle supports the following cache config options for Upstash:
60+
61+
```ts
62+
export type CacheConfig = {
63+
/**
64+
* Expiration in seconds (positive integer)
65+
*/
66+
ex?: number;
67+
/**
68+
* Set an expiration (TTL or time to live) on one or more fields of a given hash key.
69+
* Used for HEXPIRE command
70+
*/
71+
hexOptions?: "NX" | "nx" | "XX" | "xx" | "GT" | "gt" | "LT" | "lt";
72+
};
73+
```
74+
75+
## Cache usage examples
76+
77+
Once you've configured caching, here's how the cache behaves:
78+
79+
**Case 1: Drizzle with `global: false` (default, opt-in caching)**
80+
81+
```ts
82+
import { upstashCache } from "drizzle-orm/cache/upstash";
83+
import { drizzle } from "drizzle-orm/...";
84+
85+
const db = drizzle(process.env.DB_URL!, {
86+
// 👇 `global: true` is not passed, false by default
87+
cache: upstashCache({ url: "", token: "" }),
88+
});
89+
```
90+
91+
In this case, the following query won't read from cache
92+
93+
```ts
94+
const res = await db.select().from(users);
95+
96+
// Any mutate operation will still trigger the cache's onMutate handler
97+
// and attempt to invalidate any cached queries that involved the affected tables
98+
await db.insert(users).value({ email: "[email protected]" });
99+
```
100+
101+
To make this query read from the cache, call `.$withCache()`
102+
103+
```ts
104+
const res = await db.select().from(users).$withCache();
105+
```
106+
107+
`.$withCache` has a set of options you can use to manage and configure this specific query strategy
108+
109+
```ts
110+
// rewrite the config for this specific query
111+
.$withCache({ config: {} })
112+
113+
// give this query a custom cache key (instead of hashing query+params under the hood)
114+
.$withCache({ tag: 'custom_key' })
115+
116+
// turn off auto-invalidation for this query
117+
// note: this leads to eventual consistency (explained below)
118+
.$withCache({ autoInvalidate: false })
119+
```
120+
121+
<Callout>
122+
**Eventual consistency example**
123+
124+
This example is only relevant if you manually set `autoInvalidate: false`. By default, `autoInvalidate` is enabled.
125+
126+
You might want to turn off `autoInvalidate` if:
127+
- your data doesn't change often, and slight staleness is acceptable (e.g. product listings, blog posts)
128+
- you handle cache invalidation manually
129+
130+
In those cases, turning it off can reduce unnecessary cache invalidation. However, in most cases, we recommend keeping the default enabled.
131+
132+
Example: Imagine you cache the following query on `usersTable` with a 3-second TTL:
133+
134+
``` ts
135+
const recent = await db
136+
.select().from(usersTable)
137+
.$withCache({ config: { ex: 3 }, autoInvalidate: false });
138+
```
139+
140+
If someone runs `db.insert(usersTable)...` the cache won't be invalidated immediately. For up to 3 seconds, you'll keep seeing the old data until it eventually becomes consistent.
141+
</Callout>
142+
143+
**Case 2: Drizzle with `global: true` option**
144+
145+
```ts
146+
import { upstashCache } from "drizzle-orm/cache/upstash";
147+
import { drizzle } from "drizzle-orm/...";
148+
149+
const db = drizzle(process.env.DB_URL!, {
150+
cache: upstashCache({ url: "", token: "", global: true }),
151+
});
152+
```
153+
154+
In this case, the following query will read from cache
155+
156+
```ts
157+
const res = await db.select().from(users);
158+
```
159+
160+
If you want to disable cache for this specific query, call `.$withCache(false)`
161+
162+
```ts
163+
// disable cache for this query
164+
const res = await db.select().from(users).$withCache(false);
165+
```
166+
167+
You can also use cache instance from a `db` to invalidate specific tables or tags
168+
169+
```ts
170+
// Invalidate all queries that use the `users` table. You can do this with the Drizzle instance.
171+
await db.$cache?.invalidate({ tables: users });
172+
// or
173+
await db.$cache?.invalidate({ tables: [users, posts] });
174+
175+
// Invalidate all queries that use the `usersTable`. You can do this by using just the table name.
176+
await db.$cache?.invalidate({ tables: "usersTable" });
177+
// or
178+
await db.$cache?.invalidate({ tables: ["usersTable", "postsTable"] });
179+
180+
// You can also invalidate custom tags defined in any previously executed select queries.
181+
await db.$cache?.invalidate({ tags: "custom_key" });
182+
// or
183+
await db.$cache?.invalidate({ tags: ["custom_key", "custom_key1"] });
184+
```
185+
186+
## Custom cache
187+
188+
This example shows how to plug in a custom `cache` in Drizzle: you provide functions to fetch data from the cache, store results back into cache, and invalidate entries whenever a mutation runs.
189+
190+
Cache extension provides this set of config options
191+
```ts
192+
export type CacheConfig = {
193+
/** expire time, in seconds */
194+
ex?: number;
195+
/** expire time, in milliseconds */
196+
px?: number;
197+
/** Unix time (sec) at which the key will expire */
198+
exat?: number;
199+
/** Unix time (ms) at which the key will expire */
200+
pxat?: number;
201+
/** retain existing TTL when updating a key */
202+
keepTtl?: boolean;
203+
/** options for HEXPIRE (hash-field TTL) */
204+
hexOptions?: 'NX' | 'XX' | 'GT' | 'LT' | 'nx' | 'xx' | 'gt' | 'lt';
205+
};
206+
```
207+
208+
```ts
209+
const db = drizzle(process.env.DB_URL!, { cache: new TestGlobalCache() });
210+
```
211+
212+
```ts
213+
import Keyv from "keyv";
214+
215+
export class TestGlobalCache extends Cache {
216+
private globalTtl: number = 1000;
217+
// This object will be used to store which query keys were used
218+
// for a specific table, so we can later use it for invalidation.
219+
private usedTablesPerKey: Record<string, string[]> = {};
220+
221+
constructor(private kv: Keyv = new Keyv()) {
222+
super();
223+
}
224+
225+
// For the strategy, we have two options:
226+
// - 'explicit': The cache is used only when .$withCache() is added to a query.
227+
// - 'all': All queries are cached globally.
228+
// The default behavior is 'explicit'.
229+
override strategy(): "explicit" | "all" {
230+
return "all";
231+
}
232+
233+
// This function accepts query and parameters that cached into key param,
234+
// allowing you to retrieve response values for this query from the cache.
235+
override async get(key: string): Promise<any[] | undefined> {
236+
const res = (await this.kv.get(key)) ?? undefined;
237+
return res;
238+
}
239+
240+
// This function accepts several options to define how cached data will be stored:
241+
// - 'key': A hashed query and parameters.
242+
// - 'response': An array of values returned by Drizzle from the database.
243+
// - 'tables': An array of tables involved in the select queries. This information is needed for cache invalidation.
244+
//
245+
// For example, if a query uses the "users" and "posts" tables, you can store this information. Later, when the app executes
246+
// any mutation statements on these tables, you can remove the corresponding key from the cache.
247+
// If you're okay with eventual consistency for your queries, you can skip this option.
248+
override async put(
249+
key: string,
250+
response: any,
251+
tables: string[],
252+
config?: CacheConfig,
253+
): Promise<void> {
254+
await this.kv.set(key, response, config ? config.ex : this.globalTtl);
255+
for (const table of tables) {
256+
const keys = this.usedTablesPerKey[table];
257+
if (keys === undefined) {
258+
this.usedTablesPerKey[table] = [key];
259+
} else {
260+
keys.push(key);
261+
}
262+
}
263+
}
264+
265+
// This function is called when insert, update, or delete statements are executed.
266+
// You can either skip this step or invalidate queries that used the affected tables.
267+
//
268+
// The function receives an object with two keys:
269+
// - 'tags': Used for queries labeled with a specific tag, allowing you to invalidate by that tag.
270+
// - 'tables': The actual tables affected by the insert, update, or delete statements,
271+
// helping you track which tables have changed since the last cache update.
272+
override async onMutate(params: {
273+
tags: string | string[];
274+
tables: string | string[] | Table<any> | Table<any>[];
275+
}): Promise<void> {
276+
const tagsArray = params.tags
277+
? Array.isArray(params.tags)
278+
? params.tags
279+
: [params.tags]
280+
: [];
281+
const tablesArray = params.tables
282+
? Array.isArray(params.tables)
283+
? params.tables
284+
: [params.tables]
285+
: [];
286+
287+
const keysToDelete = new Set<string>();
288+
289+
for (const table of tablesArray) {
290+
const tableName = is(table, Table)
291+
? getTableName(table)
292+
: (table as string);
293+
const keys = this.usedTablesPerKey[tableName] ?? [];
294+
for (const key of keys) keysToDelete.add(key);
295+
}
296+
297+
if (keysToDelete.size > 0 || tagsArray.length > 0) {
298+
for (const tag of tagsArray) {
299+
await this.kv.delete(tag);
300+
}
301+
302+
for (const key of keysToDelete) {
303+
await this.kv.delete(key);
304+
for (const table of tablesArray) {
305+
const tableName = is(table, Table)
306+
? getTableName(table)
307+
: (table as string);
308+
this.usedTablesPerKey[tableName] = [];
309+
}
310+
}
311+
}
312+
}
313+
}
314+
```
315+
316+
## Limitations
317+
318+
#### Queries that won't be handled by the `cache` extension:
319+
320+
- Using cache with raw queries, such as:
321+
322+
```ts
323+
db.execute(sql`select 1`);
324+
```
325+
326+
- Using cache with `batch` feature in `d1` and `libsql`
327+
328+
```ts
329+
db.batch([
330+
db.insert(users).values(...),
331+
db.update(users).set(...).where()
332+
])
333+
```
334+
335+
- Using cache in transactions
336+
```ts
337+
await db.transaction(async (tx) => {
338+
await tx.update(accounts).set(...).where(...);
339+
await tx.update...
340+
});
341+
```
342+
343+
#### Limitations that are temporary and will be handled soon:
344+
345+
- Using cache with Drizzle Relational Queries
346+
```ts
347+
await db.query.users.findMany();
348+
```
349+
350+
- Using cache with `better-sqlite3`, `Durable Objects`, `expo sqlite`
351+
- Using cache with AWS Data API drivers
352+
- Using cache with views

0 commit comments

Comments
 (0)