drizzle-redis 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +43 -0
- package/src/bun/cache.ts +334 -0
- package/src/bun/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/redis/cache.ts +305 -0
- package/src/types/main.ts +74 -0
- package/tsconfig.json +29 -0
- package/tsdown.config.ts +35 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "drizzle-redis",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": {
|
|
8
|
+
"types": "./build/index.d.mts",
|
|
9
|
+
"default": "./build/index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"require": {
|
|
12
|
+
"types": "./build/index.d.cts",
|
|
13
|
+
"default": "./build/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"./bun": {
|
|
17
|
+
"import": {
|
|
18
|
+
"types": "./build/bun/index.d.mts",
|
|
19
|
+
"default": "./build/bun/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"require": {
|
|
22
|
+
"types": "./build/bun/index.d.cts",
|
|
23
|
+
"default": "./build/bun/index.cjs"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc --noEmit --incremental false",
|
|
29
|
+
"build": "tsdown",
|
|
30
|
+
"dev": "tsdown --watch ./src"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {},
|
|
33
|
+
"devDependencies": {},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"drizzle-orm": "^0.45.1",
|
|
36
|
+
"ioredis": "^5.4.2"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"ioredis": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/bun/cache.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { RedisClient } from "bun";
|
|
2
|
+
import { Table, getTableName } from "drizzle-orm";
|
|
3
|
+
import type { MutationOption } from "drizzle-orm/cache/core";
|
|
4
|
+
import { Cache } from "drizzle-orm/cache/core";
|
|
5
|
+
import type { CacheConfig } from "drizzle-orm/cache/core/types";
|
|
6
|
+
import { entityKind, is } from "drizzle-orm/entity";
|
|
7
|
+
import type { BunCacheOptions } from "../types/main";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lua script to atomically get a cached value by tag.
|
|
11
|
+
* Looks up the composite table name from the tags map, then retrieves the value.
|
|
12
|
+
*/
|
|
13
|
+
const getByTagScript = `
|
|
14
|
+
local tagsMapKey = KEYS[1] -- tags map key
|
|
15
|
+
local tag = ARGV[1] -- tag
|
|
16
|
+
|
|
17
|
+
local compositeTableName = redis.call('HGET', tagsMapKey, tag)
|
|
18
|
+
if not compositeTableName then
|
|
19
|
+
return nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local value = redis.call('HGET', compositeTableName, tag)
|
|
23
|
+
return value
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lua script to atomically invalidate cache entries on mutation.
|
|
28
|
+
* Handles both tag-based and table-based invalidation.
|
|
29
|
+
*/
|
|
30
|
+
const onMutateScript = `
|
|
31
|
+
local tagsMapKey = KEYS[1] -- tags map key
|
|
32
|
+
local tables = {} -- initialize tables array
|
|
33
|
+
local tags = ARGV -- tags array
|
|
34
|
+
|
|
35
|
+
for i = 2, #KEYS do
|
|
36
|
+
tables[#tables + 1] = KEYS[i] -- add all keys except the first one to tables
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if #tags > 0 then
|
|
40
|
+
for _, tag in ipairs(tags) do
|
|
41
|
+
if tag ~= nil and tag ~= '' then
|
|
42
|
+
local compositeTableName = redis.call('HGET', tagsMapKey, tag)
|
|
43
|
+
if compositeTableName then
|
|
44
|
+
redis.call('HDEL', compositeTableName, tag)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
redis.call('HDEL', tagsMapKey, unpack(tags))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
local keysToDelete = {}
|
|
52
|
+
|
|
53
|
+
if #tables > 0 then
|
|
54
|
+
local compositeTableNames = redis.call('SUNION', unpack(tables))
|
|
55
|
+
for _, compositeTableName in ipairs(compositeTableNames) do
|
|
56
|
+
keysToDelete[#keysToDelete + 1] = compositeTableName
|
|
57
|
+
end
|
|
58
|
+
for _, table in ipairs(tables) do
|
|
59
|
+
keysToDelete[#keysToDelete + 1] = table
|
|
60
|
+
end
|
|
61
|
+
redis.call('DEL', unpack(keysToDelete))
|
|
62
|
+
end
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
interface InternalConfig {
|
|
66
|
+
seconds: number;
|
|
67
|
+
hexOptions?: "NX" | "nx" | "XX" | "xx" | "GT" | "gt" | "LT" | "lt";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class BunCache extends Cache {
|
|
71
|
+
static override readonly [entityKind]: string = "BunCache";
|
|
72
|
+
|
|
73
|
+
private readonly client: RedisClient;
|
|
74
|
+
private readonly prefix: string;
|
|
75
|
+
private readonly useGlobally: boolean;
|
|
76
|
+
private readonly internalConfig: InternalConfig;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Prefix for sets which denote the composite table names for each unique table.
|
|
80
|
+
*
|
|
81
|
+
* Example: In the composite table set of "table1", you may find
|
|
82
|
+
* `${compositeTableSetPrefix}table1,table2` and `${compositeTableSetPrefix}table1,table3`
|
|
83
|
+
*/
|
|
84
|
+
private static readonly compositeTableSetPrefix = "__CTS__";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Prefix for hashes which map hash or tags to cache values.
|
|
88
|
+
*/
|
|
89
|
+
private static readonly compositeTablePrefix = "__CT__";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Key which holds the mapping of tags to composite table names.
|
|
93
|
+
*
|
|
94
|
+
* Using this tagsMapKey, you can find the composite table name for a given tag
|
|
95
|
+
* and get the cache value for that tag.
|
|
96
|
+
*/
|
|
97
|
+
private static readonly tagsMapKey = "__tagsMap__";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Queries whose auto invalidation is false aren't stored in their respective
|
|
101
|
+
* composite table hashes because those hashes are deleted when a mutation
|
|
102
|
+
* occurs on related tables.
|
|
103
|
+
*
|
|
104
|
+
* Instead, they are stored in a separate hash with this prefix
|
|
105
|
+
* to prevent them from being deleted when a mutation occurs.
|
|
106
|
+
*/
|
|
107
|
+
private static readonly nonAutoInvalidateTablePrefix =
|
|
108
|
+
"__nonAutoInvalidate__";
|
|
109
|
+
|
|
110
|
+
constructor(options: BunCacheOptions) {
|
|
111
|
+
super();
|
|
112
|
+
this.prefix = options.prefix ?? "drizzle-redis";
|
|
113
|
+
this.useGlobally = options.global ?? false;
|
|
114
|
+
this.internalConfig = this.toInternalConfig(options.config);
|
|
115
|
+
|
|
116
|
+
if ("client" in options) {
|
|
117
|
+
this.client = options.client;
|
|
118
|
+
} else {
|
|
119
|
+
this.client = new RedisClient(options.url);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private toInternalConfig(config?: CacheConfig): InternalConfig {
|
|
124
|
+
return config
|
|
125
|
+
? {
|
|
126
|
+
seconds: config.ex ?? 1,
|
|
127
|
+
hexOptions: config.hexOptions,
|
|
128
|
+
}
|
|
129
|
+
: {
|
|
130
|
+
seconds: 1,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
override strategy(): "explicit" | "all" {
|
|
135
|
+
return this.useGlobally ? "all" : "explicit";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
override async get(
|
|
139
|
+
key: string,
|
|
140
|
+
tables: string[],
|
|
141
|
+
isTag: boolean,
|
|
142
|
+
isAutoInvalidate?: boolean
|
|
143
|
+
): Promise<any[] | undefined> {
|
|
144
|
+
// Handle non-auto-invalidate queries
|
|
145
|
+
if (!isAutoInvalidate) {
|
|
146
|
+
const result = await this.client.hget(
|
|
147
|
+
this.addPrefix(BunCache.nonAutoInvalidateTablePrefix),
|
|
148
|
+
key
|
|
149
|
+
);
|
|
150
|
+
return result === null ? undefined : JSON.parse(result);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Handle tag-based lookup using Lua script
|
|
154
|
+
if (isTag) {
|
|
155
|
+
const result = await this.client.send("EVAL", [
|
|
156
|
+
getByTagScript,
|
|
157
|
+
"1",
|
|
158
|
+
this.addPrefix(BunCache.tagsMapKey),
|
|
159
|
+
key,
|
|
160
|
+
]);
|
|
161
|
+
if (result === null) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
return JSON.parse(result as string);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Handle normal table-based lookup
|
|
168
|
+
const compositeKey = this.getCompositeKey(tables);
|
|
169
|
+
const result = await this.client.hget(compositeKey, key);
|
|
170
|
+
return result === null ? undefined : JSON.parse(result);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
override async put(
|
|
174
|
+
key: string,
|
|
175
|
+
response: any,
|
|
176
|
+
tables: string[],
|
|
177
|
+
isTag: boolean = false,
|
|
178
|
+
config?: CacheConfig
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const isAutoInvalidate = tables.length !== 0;
|
|
181
|
+
const ttlSeconds =
|
|
182
|
+
config && config.ex ? config.ex : this.internalConfig.seconds;
|
|
183
|
+
const hexOptions =
|
|
184
|
+
config && config.hexOptions
|
|
185
|
+
? config.hexOptions
|
|
186
|
+
: this.internalConfig?.hexOptions;
|
|
187
|
+
|
|
188
|
+
const serializedResponse = JSON.stringify(response);
|
|
189
|
+
|
|
190
|
+
// Handle non-auto-invalidate queries
|
|
191
|
+
if (!isAutoInvalidate) {
|
|
192
|
+
const nonAutoInvalidateKey = this.addPrefix(
|
|
193
|
+
BunCache.nonAutoInvalidateTablePrefix
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const commands: Promise<any>[] = [];
|
|
197
|
+
|
|
198
|
+
if (isTag) {
|
|
199
|
+
const tagsMapKey = this.addPrefix(BunCache.tagsMapKey);
|
|
200
|
+
commands.push(
|
|
201
|
+
this.client.send("HSET", [tagsMapKey, key, nonAutoInvalidateKey])
|
|
202
|
+
);
|
|
203
|
+
commands.push(this.hexpire(tagsMapKey, key, ttlSeconds, hexOptions));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
commands.push(
|
|
207
|
+
this.client.send("HSET", [
|
|
208
|
+
nonAutoInvalidateKey,
|
|
209
|
+
key,
|
|
210
|
+
serializedResponse,
|
|
211
|
+
])
|
|
212
|
+
);
|
|
213
|
+
commands.push(
|
|
214
|
+
this.hexpire(nonAutoInvalidateKey, key, ttlSeconds, hexOptions)
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await Promise.all(commands);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Handle auto-invalidate queries
|
|
222
|
+
const compositeKey = this.getCompositeKey(tables);
|
|
223
|
+
const commands: Promise<any>[] = [];
|
|
224
|
+
|
|
225
|
+
commands.push(
|
|
226
|
+
this.client.send("HSET", [compositeKey, key, serializedResponse])
|
|
227
|
+
);
|
|
228
|
+
commands.push(this.hexpire(compositeKey, key, ttlSeconds, hexOptions));
|
|
229
|
+
|
|
230
|
+
if (isTag) {
|
|
231
|
+
const tagsMapKey = this.addPrefix(BunCache.tagsMapKey);
|
|
232
|
+
commands.push(this.client.send("HSET", [tagsMapKey, key, compositeKey]));
|
|
233
|
+
commands.push(this.hexpire(tagsMapKey, key, ttlSeconds, hexOptions));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Track composite keys for each table (for invalidation)
|
|
237
|
+
for (const table of tables) {
|
|
238
|
+
commands.push(this.client.sadd(this.addTablePrefix(table), compositeKey));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await Promise.all(commands);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
override async onMutate(params: MutationOption): Promise<void> {
|
|
245
|
+
const tags = Array.isArray(params.tags)
|
|
246
|
+
? params.tags
|
|
247
|
+
: params.tags
|
|
248
|
+
? [params.tags]
|
|
249
|
+
: [];
|
|
250
|
+
|
|
251
|
+
const tables = Array.isArray(params.tables)
|
|
252
|
+
? params.tables
|
|
253
|
+
: params.tables
|
|
254
|
+
? [params.tables]
|
|
255
|
+
: [];
|
|
256
|
+
|
|
257
|
+
// Extract table names, handling Table objects via is() + getTableName
|
|
258
|
+
const tableNames = tables.map((table) =>
|
|
259
|
+
is(table, Table) ? getTableName(table) : (table as string)
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const compositeTableSets = tableNames.map((table) =>
|
|
263
|
+
this.addTablePrefix(table)
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const tagsMapKey = this.addPrefix(BunCache.tagsMapKey);
|
|
267
|
+
|
|
268
|
+
// Execute the Lua script for atomic invalidation
|
|
269
|
+
await this.client.send("EVAL", [
|
|
270
|
+
onMutateScript,
|
|
271
|
+
(1 + compositeTableSets.length).toString(),
|
|
272
|
+
tagsMapKey,
|
|
273
|
+
...compositeTableSets,
|
|
274
|
+
...tags,
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Add the user-defined prefix to a key.
|
|
280
|
+
*/
|
|
281
|
+
private addPrefix(key: string): string {
|
|
282
|
+
return `${this.prefix}:${key}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Add the composite table set prefix with user prefix.
|
|
287
|
+
*/
|
|
288
|
+
private addTablePrefix(table: string): string {
|
|
289
|
+
return this.addPrefix(`${BunCache.compositeTableSetPrefix}${table}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Generate a composite key from sorted table names.
|
|
294
|
+
*/
|
|
295
|
+
private getCompositeKey(tables: string[]): string {
|
|
296
|
+
return this.addPrefix(
|
|
297
|
+
`${BunCache.compositeTablePrefix}${tables.sort().join(",")}`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Execute HEXPIRE command using send().
|
|
303
|
+
* HEXPIRE requires Redis 7.4+
|
|
304
|
+
*/
|
|
305
|
+
private hexpire(
|
|
306
|
+
key: string,
|
|
307
|
+
field: string,
|
|
308
|
+
seconds: number,
|
|
309
|
+
hexOptions?: "NX" | "nx" | "XX" | "xx" | "GT" | "gt" | "LT" | "lt"
|
|
310
|
+
): Promise<any> {
|
|
311
|
+
if (hexOptions) {
|
|
312
|
+
return this.client.send("HEXPIRE", [
|
|
313
|
+
key,
|
|
314
|
+
seconds.toString(),
|
|
315
|
+
hexOptions,
|
|
316
|
+
"FIELDS",
|
|
317
|
+
"1",
|
|
318
|
+
field,
|
|
319
|
+
]);
|
|
320
|
+
} else {
|
|
321
|
+
return this.client.send("HEXPIRE", [
|
|
322
|
+
key,
|
|
323
|
+
seconds.toString(),
|
|
324
|
+
"FIELDS",
|
|
325
|
+
"1",
|
|
326
|
+
field,
|
|
327
|
+
]);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function bunCache(options: BunCacheOptions): BunCache {
|
|
333
|
+
return new BunCache(options);
|
|
334
|
+
}
|
package/src/bun/index.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { MutationOption } from "drizzle-orm/cache/core";
|
|
2
|
+
import { Cache } from "drizzle-orm/cache/core";
|
|
3
|
+
import type { CacheConfig } from "drizzle-orm/cache/core/types";
|
|
4
|
+
import { is } from "drizzle-orm/entity";
|
|
5
|
+
import { Table, getTableName } from "drizzle-orm";
|
|
6
|
+
import Redis from "ioredis";
|
|
7
|
+
import type { RedisCacheOptions } from "../types/main";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lua script to atomically get a cached value by tag.
|
|
11
|
+
* Looks up the composite table name from the tags map, then retrieves the value.
|
|
12
|
+
*/
|
|
13
|
+
const getByTagScript = `
|
|
14
|
+
local tagsMapKey = KEYS[1] -- tags map key
|
|
15
|
+
local tag = ARGV[1] -- tag
|
|
16
|
+
|
|
17
|
+
local compositeTableName = redis.call('HGET', tagsMapKey, tag)
|
|
18
|
+
if not compositeTableName then
|
|
19
|
+
return nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local value = redis.call('HGET', compositeTableName, tag)
|
|
23
|
+
return value
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lua script to atomically invalidate cache entries on mutation.
|
|
28
|
+
* Handles both tag-based and table-based invalidation.
|
|
29
|
+
*/
|
|
30
|
+
const onMutateScript = `
|
|
31
|
+
local tagsMapKey = KEYS[1] -- tags map key
|
|
32
|
+
local tables = {} -- initialize tables array
|
|
33
|
+
local tags = ARGV -- tags array
|
|
34
|
+
|
|
35
|
+
for i = 2, #KEYS do
|
|
36
|
+
tables[#tables + 1] = KEYS[i] -- add all keys except the first one to tables
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if #tags > 0 then
|
|
40
|
+
for _, tag in ipairs(tags) do
|
|
41
|
+
if tag ~= nil and tag ~= '' then
|
|
42
|
+
local compositeTableName = redis.call('HGET', tagsMapKey, tag)
|
|
43
|
+
if compositeTableName then
|
|
44
|
+
redis.call('HDEL', compositeTableName, tag)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
redis.call('HDEL', tagsMapKey, unpack(tags))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
local keysToDelete = {}
|
|
52
|
+
|
|
53
|
+
if #tables > 0 then
|
|
54
|
+
local compositeTableNames = redis.call('SUNION', unpack(tables))
|
|
55
|
+
for _, compositeTableName in ipairs(compositeTableNames) do
|
|
56
|
+
keysToDelete[#keysToDelete + 1] = compositeTableName
|
|
57
|
+
end
|
|
58
|
+
for _, table in ipairs(tables) do
|
|
59
|
+
keysToDelete[#keysToDelete + 1] = table
|
|
60
|
+
end
|
|
61
|
+
redis.call('DEL', unpack(keysToDelete))
|
|
62
|
+
end
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
interface InternalConfig {
|
|
66
|
+
seconds: number;
|
|
67
|
+
hexOptions?: "NX" | "nx" | "XX" | "xx" | "GT" | "gt" | "LT" | "lt";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class RedisCache extends Cache {
|
|
71
|
+
private readonly client: Redis;
|
|
72
|
+
private readonly prefix: string;
|
|
73
|
+
private readonly useGlobally: boolean;
|
|
74
|
+
private readonly internalConfig: InternalConfig;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Prefix for sets which denote the composite table names for each unique table.
|
|
78
|
+
*
|
|
79
|
+
* Example: In the composite table set of "table1", you may find
|
|
80
|
+
* `${compositeTableSetPrefix}table1,table2` and `${compositeTableSetPrefix}table1,table3`
|
|
81
|
+
*/
|
|
82
|
+
private static readonly compositeTableSetPrefix = "__CTS__";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Prefix for hashes which map hash or tags to cache values.
|
|
86
|
+
*/
|
|
87
|
+
private static readonly compositeTablePrefix = "__CT__";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Key which holds the mapping of tags to composite table names.
|
|
91
|
+
*
|
|
92
|
+
* Using this tagsMapKey, you can find the composite table name for a given tag
|
|
93
|
+
* and get the cache value for that tag.
|
|
94
|
+
*/
|
|
95
|
+
private static readonly tagsMapKey = "__tagsMap__";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Queries whose auto invalidation is false aren't stored in their respective
|
|
99
|
+
* composite table hashes because those hashes are deleted when a mutation
|
|
100
|
+
* occurs on related tables.
|
|
101
|
+
*
|
|
102
|
+
* Instead, they are stored in a separate hash with this prefix
|
|
103
|
+
* to prevent them from being deleted when a mutation occurs.
|
|
104
|
+
*/
|
|
105
|
+
private static readonly nonAutoInvalidateTablePrefix =
|
|
106
|
+
"__nonAutoInvalidate__";
|
|
107
|
+
|
|
108
|
+
constructor(options: RedisCacheOptions) {
|
|
109
|
+
super();
|
|
110
|
+
this.prefix = options.prefix ?? "drizzle-redis";
|
|
111
|
+
this.useGlobally = options.global ?? false;
|
|
112
|
+
this.internalConfig = this.toInternalConfig(options.config);
|
|
113
|
+
|
|
114
|
+
if ("client" in options) {
|
|
115
|
+
this.client = options.client;
|
|
116
|
+
} else {
|
|
117
|
+
this.client = new Redis(options.url);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private toInternalConfig(config?: CacheConfig): InternalConfig {
|
|
122
|
+
return config
|
|
123
|
+
? {
|
|
124
|
+
seconds: config.ex ?? 1,
|
|
125
|
+
hexOptions: config.hexOptions,
|
|
126
|
+
}
|
|
127
|
+
: {
|
|
128
|
+
seconds: 1,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override strategy(): "explicit" | "all" {
|
|
133
|
+
return this.useGlobally ? "all" : "explicit";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
override async get(
|
|
137
|
+
key: string,
|
|
138
|
+
tables: string[],
|
|
139
|
+
isTag: boolean,
|
|
140
|
+
isAutoInvalidate?: boolean
|
|
141
|
+
): Promise<any[] | undefined> {
|
|
142
|
+
// Handle non-auto-invalidate queries
|
|
143
|
+
if (!isAutoInvalidate) {
|
|
144
|
+
const result = await this.client.hget(
|
|
145
|
+
this.addPrefix(RedisCache.nonAutoInvalidateTablePrefix),
|
|
146
|
+
key
|
|
147
|
+
);
|
|
148
|
+
return result === null ? undefined : JSON.parse(result);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handle tag-based lookup using Lua script
|
|
152
|
+
if (isTag) {
|
|
153
|
+
const result = await this.client.eval(
|
|
154
|
+
getByTagScript,
|
|
155
|
+
1,
|
|
156
|
+
this.addPrefix(RedisCache.tagsMapKey),
|
|
157
|
+
key
|
|
158
|
+
);
|
|
159
|
+
if (result === null) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
return JSON.parse(result as string);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Handle normal table-based lookup
|
|
166
|
+
const compositeKey = this.getCompositeKey(tables);
|
|
167
|
+
const result = await this.client.hget(compositeKey, key);
|
|
168
|
+
return result === null ? undefined : JSON.parse(result);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override async put(
|
|
172
|
+
key: string,
|
|
173
|
+
response: any,
|
|
174
|
+
tables: string[],
|
|
175
|
+
isTag: boolean = false,
|
|
176
|
+
config?: CacheConfig
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
const isAutoInvalidate = tables.length !== 0;
|
|
179
|
+
const pipeline = this.client.pipeline();
|
|
180
|
+
const ttlSeconds =
|
|
181
|
+
config && config.ex ? config.ex : this.internalConfig.seconds;
|
|
182
|
+
const hexOptions =
|
|
183
|
+
config && config.hexOptions
|
|
184
|
+
? config.hexOptions
|
|
185
|
+
: this.internalConfig?.hexOptions;
|
|
186
|
+
|
|
187
|
+
const serializedResponse = JSON.stringify(response);
|
|
188
|
+
|
|
189
|
+
// Handle non-auto-invalidate queries
|
|
190
|
+
if (!isAutoInvalidate) {
|
|
191
|
+
const nonAutoInvalidateKey = this.addPrefix(
|
|
192
|
+
RedisCache.nonAutoInvalidateTablePrefix
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (isTag) {
|
|
196
|
+
const tagsMapKey = this.addPrefix(RedisCache.tagsMapKey);
|
|
197
|
+
pipeline.hset(tagsMapKey, key, nonAutoInvalidateKey);
|
|
198
|
+
this.hexpire(pipeline, tagsMapKey, key, ttlSeconds, hexOptions);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
pipeline.hset(nonAutoInvalidateKey, key, serializedResponse);
|
|
202
|
+
this.hexpire(pipeline, nonAutoInvalidateKey, key, ttlSeconds, hexOptions);
|
|
203
|
+
await pipeline.exec();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Handle auto-invalidate queries
|
|
208
|
+
const compositeKey = this.getCompositeKey(tables);
|
|
209
|
+
|
|
210
|
+
pipeline.hset(compositeKey, key, serializedResponse);
|
|
211
|
+
this.hexpire(pipeline, compositeKey, key, ttlSeconds, hexOptions);
|
|
212
|
+
|
|
213
|
+
if (isTag) {
|
|
214
|
+
const tagsMapKey = this.addPrefix(RedisCache.tagsMapKey);
|
|
215
|
+
pipeline.hset(tagsMapKey, key, compositeKey);
|
|
216
|
+
this.hexpire(pipeline, tagsMapKey, key, ttlSeconds, hexOptions);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Track composite keys for each table (for invalidation)
|
|
220
|
+
for (const table of tables) {
|
|
221
|
+
pipeline.sadd(this.addTablePrefix(table), compositeKey);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await pipeline.exec();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
override async onMutate(params: MutationOption): Promise<void> {
|
|
228
|
+
const tags = Array.isArray(params.tags)
|
|
229
|
+
? params.tags
|
|
230
|
+
: params.tags
|
|
231
|
+
? [params.tags]
|
|
232
|
+
: [];
|
|
233
|
+
|
|
234
|
+
const tables = Array.isArray(params.tables)
|
|
235
|
+
? params.tables
|
|
236
|
+
: params.tables
|
|
237
|
+
? [params.tables]
|
|
238
|
+
: [];
|
|
239
|
+
|
|
240
|
+
// Extract table names, handling Table objects via is() + getTableName
|
|
241
|
+
const tableNames = tables.map((table) =>
|
|
242
|
+
is(table, Table) ? getTableName(table) : (table as string)
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const compositeTableSets = tableNames.map((table) =>
|
|
246
|
+
this.addTablePrefix(table)
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const tagsMapKey = this.addPrefix(RedisCache.tagsMapKey);
|
|
250
|
+
|
|
251
|
+
// Execute the Lua script for atomic invalidation
|
|
252
|
+
await this.client.eval(
|
|
253
|
+
onMutateScript,
|
|
254
|
+
1 + compositeTableSets.length,
|
|
255
|
+
tagsMapKey,
|
|
256
|
+
...compositeTableSets,
|
|
257
|
+
...tags
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Add the user-defined prefix to a key.
|
|
263
|
+
*/
|
|
264
|
+
private addPrefix(key: string): string {
|
|
265
|
+
return `${this.prefix}:${key}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Add the composite table set prefix with user prefix.
|
|
270
|
+
*/
|
|
271
|
+
private addTablePrefix(table: string): string {
|
|
272
|
+
return this.addPrefix(`${RedisCache.compositeTableSetPrefix}${table}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Generate a composite key from sorted table names.
|
|
277
|
+
*/
|
|
278
|
+
private getCompositeKey(tables: string[]): string {
|
|
279
|
+
return this.addPrefix(
|
|
280
|
+
`${RedisCache.compositeTablePrefix}${tables.sort().join(",")}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Execute HEXPIRE command using pipeline.call() since ioredis doesn't have a native method.
|
|
286
|
+
* HEXPIRE requires Redis 7.4+
|
|
287
|
+
*/
|
|
288
|
+
private hexpire(
|
|
289
|
+
pipeline: ReturnType<Redis["pipeline"]>,
|
|
290
|
+
key: string,
|
|
291
|
+
field: string,
|
|
292
|
+
seconds: number,
|
|
293
|
+
hexOptions?: "NX" | "nx" | "XX" | "xx" | "GT" | "gt" | "LT" | "lt"
|
|
294
|
+
): void {
|
|
295
|
+
if (hexOptions) {
|
|
296
|
+
pipeline.call("HEXPIRE", key, seconds, hexOptions, "FIELDS", 1, field);
|
|
297
|
+
} else {
|
|
298
|
+
pipeline.call("HEXPIRE", key, seconds, "FIELDS", 1, field);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function redisCache(options: RedisCacheOptions): RedisCache {
|
|
304
|
+
return new RedisCache(options);
|
|
305
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { RedisClient } from "bun";
|
|
2
|
+
import type { CacheConfig } from "drizzle-orm/cache/core/types";
|
|
3
|
+
import type { Redis } from "ioredis";
|
|
4
|
+
|
|
5
|
+
export interface RedisCacheWithClient {
|
|
6
|
+
/**
|
|
7
|
+
* The Redis client to use for the cache.
|
|
8
|
+
*/
|
|
9
|
+
client: Redis;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RedisCacheWithConnection {
|
|
13
|
+
/**
|
|
14
|
+
* The URL to use to connect to the Redis server.
|
|
15
|
+
*/
|
|
16
|
+
url: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RedisCacheOptions = (
|
|
20
|
+
| RedisCacheWithClient
|
|
21
|
+
| RedisCacheWithConnection
|
|
22
|
+
) & {
|
|
23
|
+
/**
|
|
24
|
+
* The prefix to use for the cache keys. Defaults to "drizzle-redis".
|
|
25
|
+
* @default "drizzle-redis"
|
|
26
|
+
*/
|
|
27
|
+
prefix?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Default cache configuration (TTL settings).
|
|
30
|
+
*/
|
|
31
|
+
config?: CacheConfig;
|
|
32
|
+
/**
|
|
33
|
+
* Whether to enable global caching for all queries.
|
|
34
|
+
* When true, all queries will be cached automatically.
|
|
35
|
+
* When false (default), only queries with explicit .cache() will be cached.
|
|
36
|
+
* @default false
|
|
37
|
+
*/
|
|
38
|
+
global?: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Bun Redis Cache Types
|
|
42
|
+
|
|
43
|
+
export interface BunCacheWithClient {
|
|
44
|
+
/**
|
|
45
|
+
* The Bun RedisClient instance to use for the cache.
|
|
46
|
+
*/
|
|
47
|
+
client: RedisClient;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BunCacheWithConnection {
|
|
51
|
+
/**
|
|
52
|
+
* The URL to use to connect to the Redis server.
|
|
53
|
+
*/
|
|
54
|
+
url: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type BunCacheOptions = (BunCacheWithClient | BunCacheWithConnection) & {
|
|
58
|
+
/**
|
|
59
|
+
* The prefix to use for the cache keys. Defaults to "drizzle-redis".
|
|
60
|
+
* @default "drizzle-redis"
|
|
61
|
+
*/
|
|
62
|
+
prefix?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Default cache configuration (TTL settings).
|
|
65
|
+
*/
|
|
66
|
+
config?: CacheConfig;
|
|
67
|
+
/**
|
|
68
|
+
* Whether to enable global caching for all queries.
|
|
69
|
+
* When true, all queries will be cached automatically.
|
|
70
|
+
* When false (default), only queries with explicit .cache() will be cached.
|
|
71
|
+
* @default false
|
|
72
|
+
*/
|
|
73
|
+
global?: boolean;
|
|
74
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|
package/tsdown.config.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineConfig, type UserConfig } from "tsdown";
|
|
2
|
+
import swc from "unplugin-swc";
|
|
3
|
+
|
|
4
|
+
const baseConfig: UserConfig = {
|
|
5
|
+
format: ["cjs", "esm"],
|
|
6
|
+
treeshake: false,
|
|
7
|
+
dts: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
clean: false,
|
|
10
|
+
plugins: [
|
|
11
|
+
//
|
|
12
|
+
swc.rolldown({
|
|
13
|
+
minify: true,
|
|
14
|
+
sourceMaps: true,
|
|
15
|
+
jsc: {
|
|
16
|
+
target: "es2015",
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default defineConfig([
|
|
23
|
+
{
|
|
24
|
+
entry: ["src/index.ts"],
|
|
25
|
+
...baseConfig,
|
|
26
|
+
outDir: "build",
|
|
27
|
+
external: ["drizzle-orm", "ioredis"],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
entry: ["src/bun/index.ts"],
|
|
31
|
+
...baseConfig,
|
|
32
|
+
outDir: "build/bun",
|
|
33
|
+
external: ["drizzle-orm", "bun"],
|
|
34
|
+
},
|
|
35
|
+
]);
|