@quark-fw/cache 0.1.18
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/README.md +38 -0
- package/package.json +36 -0
- package/src/QuarkCache.test.ts +151 -0
- package/src/QuarkCache.ts +226 -0
- package/src/global.d.ts +11 -0
- package/src/index.ts +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @quark-fw/cache
|
|
2
|
+
|
|
3
|
+
Read-through cache for Quark apps on top of the Redis plugin: cache a fetcher result under a key, attach tags, invalidate by tag from any instance.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @quark-fw/cache
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peers: `@quark-fw/core`, `@quark-fw/plugin-redis`, `ioredis`.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import "@quark-fw/plugin-redis";
|
|
17
|
+
import "@quark-fw/cache"; // registers the plugin, provides quark.cache
|
|
18
|
+
|
|
19
|
+
// in a tRPC procedure
|
|
20
|
+
const city = await quark.cache.get(
|
|
21
|
+
cacheKey("geo.city", input), // stable key from name + args
|
|
22
|
+
() => loadCity(input.slug), // runs only on a miss
|
|
23
|
+
{ ttl: 600, tags: ["geo"] }, // seconds; tags for invalidation
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// after an admin edit or a parser import
|
|
27
|
+
await quark.cache.invalidate("geo", "listings");
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- Values go through JSON: a `Date` comes back as a string (the same shape the tRPC client sees), on the first call too.
|
|
31
|
+
- Concurrent misses for one key share a single fetcher call in the process.
|
|
32
|
+
- Tags live in Redis, so invalidation reaches every instance.
|
|
33
|
+
- A Redis failure never fails the request: the fetcher runs and the error goes to `onError` (console by default).
|
|
34
|
+
- Env: `CACHE_PREFIX` (default `quark:cache:`), `CACHE_TTL` (default 600 s), `CACHE_DISABLED=1` to bypass.
|
|
35
|
+
|
|
36
|
+
`QuarkCache` and `cacheKey` are exported for use outside the plugin (scripts, workers): `new QuarkCache({ client: () => redis })`.
|
|
37
|
+
|
|
38
|
+
Part of the [Quark framework](https://www.npmjs.com/package/@quark-fw/core).
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quark-fw/cache",
|
|
3
|
+
"version": "0.1.18",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"types": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "echo raw-src package, no build",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"quark",
|
|
16
|
+
"cache",
|
|
17
|
+
"redis"
|
|
18
|
+
],
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"description": "Redis-backed read-through cache with tag invalidation for the Quark framework (published as raw TS sources; consumers run tsx)",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@quark-fw/plugin": "0.1.18"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@quark-fw/core": "0.1.18",
|
|
30
|
+
"@quark-fw/plugin-redis": "0.1.18",
|
|
31
|
+
"ioredis": "^5.9.3"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"vitest": "^5.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import type { Redis } from "ioredis";
|
|
3
|
+
import { cacheKey, QuarkCache } from "./QuarkCache.js";
|
|
4
|
+
|
|
5
|
+
// Фейковый ioredis: ровно те команды, которыми пользуется кэш
|
|
6
|
+
function fakeRedis() {
|
|
7
|
+
const store = new Map<string, string>();
|
|
8
|
+
const sets = new Map<string, Set<string>>();
|
|
9
|
+
const client = {
|
|
10
|
+
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
|
11
|
+
set: vi.fn(async (key: string, value: string) => {
|
|
12
|
+
store.set(key, value);
|
|
13
|
+
return "OK";
|
|
14
|
+
}),
|
|
15
|
+
del: vi.fn(async (...keys: string[]) => {
|
|
16
|
+
let n = 0;
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
if (store.delete(key)) n++;
|
|
19
|
+
if (sets.delete(key)) n++;
|
|
20
|
+
}
|
|
21
|
+
return n;
|
|
22
|
+
}),
|
|
23
|
+
sadd: vi.fn(async (key: string, member: string) => {
|
|
24
|
+
if (!sets.has(key)) sets.set(key, new Set());
|
|
25
|
+
sets.get(key)!.add(member);
|
|
26
|
+
return 1;
|
|
27
|
+
}),
|
|
28
|
+
smembers: vi.fn(async (key: string) => [...(sets.get(key) ?? [])]),
|
|
29
|
+
expire: vi.fn(async () => 1),
|
|
30
|
+
scan: vi.fn(async (_cursor: string, _m: string, pattern: string) => {
|
|
31
|
+
const prefix = pattern.replace(/\*$/, "");
|
|
32
|
+
return [
|
|
33
|
+
"0",
|
|
34
|
+
[...store.keys(), ...sets.keys()].filter((k) =>
|
|
35
|
+
k.startsWith(prefix),
|
|
36
|
+
),
|
|
37
|
+
];
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
return { client: client as unknown as Redis, store, sets };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("QuarkCache", () => {
|
|
44
|
+
test("промах зовёт fetcher один раз, попадание — из Redis, Date становится строкой", async () => {
|
|
45
|
+
const { client } = fakeRedis();
|
|
46
|
+
const cache = new QuarkCache({ client: () => client, prefix: "t:" });
|
|
47
|
+
const fetcher = vi.fn(async () => ({
|
|
48
|
+
at: new Date("2026-01-01T00:00:00Z"),
|
|
49
|
+
n: 1,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const first = await cache.get("a", fetcher);
|
|
53
|
+
const second = await cache.get("a", fetcher);
|
|
54
|
+
|
|
55
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(first).toEqual({ at: "2026-01-01T00:00:00.000Z", n: 1 });
|
|
57
|
+
expect(second).toEqual(first);
|
|
58
|
+
expect(client.set).toHaveBeenCalledWith(
|
|
59
|
+
"t:a",
|
|
60
|
+
JSON.stringify({ at: "2026-01-01T00:00:00.000Z", n: 1 }),
|
|
61
|
+
"EX",
|
|
62
|
+
600,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("параллельные промахи одного ключа делят один fetcher", async () => {
|
|
67
|
+
const { client } = fakeRedis();
|
|
68
|
+
const cache = new QuarkCache({ client: () => client });
|
|
69
|
+
let calls = 0;
|
|
70
|
+
const fetcher = () =>
|
|
71
|
+
new Promise<number>((resolve) => {
|
|
72
|
+
calls++;
|
|
73
|
+
setTimeout(() => resolve(42), 10);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const [a, b, c] = await Promise.all([
|
|
77
|
+
cache.get("k", fetcher),
|
|
78
|
+
cache.get("k", fetcher),
|
|
79
|
+
cache.get("k", fetcher),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
expect([a, b, c]).toEqual([42, 42, 42]);
|
|
83
|
+
expect(calls).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("invalidate по тегу удаляет только помеченные ключи", async () => {
|
|
87
|
+
const { client, store } = fakeRedis();
|
|
88
|
+
const cache = new QuarkCache({ client: () => client, prefix: "t:" });
|
|
89
|
+
await cache.get("listings:1", async () => 1, { tags: ["listings"] });
|
|
90
|
+
await cache.get("listings:2", async () => 2, {
|
|
91
|
+
tags: ["listings", "geo"],
|
|
92
|
+
});
|
|
93
|
+
await cache.get("cities", async () => 3, { tags: ["geo"] });
|
|
94
|
+
await cache.get("plain", async () => 4);
|
|
95
|
+
|
|
96
|
+
const removed = await cache.invalidate("listings");
|
|
97
|
+
|
|
98
|
+
expect(removed).toBe(2);
|
|
99
|
+
expect(store.has("t:listings:1")).toBe(false);
|
|
100
|
+
expect(store.has("t:listings:2")).toBe(false);
|
|
101
|
+
expect(store.has("t:cities")).toBe(true);
|
|
102
|
+
expect(store.has("t:plain")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("clear удаляет всё с префиксом", async () => {
|
|
106
|
+
const { client, store } = fakeRedis();
|
|
107
|
+
const cache = new QuarkCache({ client: () => client, prefix: "t:" });
|
|
108
|
+
await cache.get("a", async () => 1, { tags: ["x"] });
|
|
109
|
+
await cache.get("b", async () => 2);
|
|
110
|
+
store.set("other:z", "1");
|
|
111
|
+
|
|
112
|
+
await cache.clear();
|
|
113
|
+
|
|
114
|
+
expect([...store.keys()]).toEqual(["other:z"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("сбой Redis не валит запрос: значение из источника, ошибка в onError", async () => {
|
|
118
|
+
const broken = {
|
|
119
|
+
get: async () => {
|
|
120
|
+
throw new Error("ECONNREFUSED");
|
|
121
|
+
},
|
|
122
|
+
set: async () => {
|
|
123
|
+
throw new Error("ECONNREFUSED");
|
|
124
|
+
},
|
|
125
|
+
} as unknown as Redis;
|
|
126
|
+
const onError = vi.fn();
|
|
127
|
+
const cache = new QuarkCache({ client: () => broken, onError });
|
|
128
|
+
|
|
129
|
+
const value = await cache.get("k", async () => ({ ok: true }));
|
|
130
|
+
|
|
131
|
+
expect(value).toEqual({ ok: true });
|
|
132
|
+
expect(onError).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("disabled: всегда к источнику, Redis не трогается", async () => {
|
|
136
|
+
const { client } = fakeRedis();
|
|
137
|
+
const cache = new QuarkCache({ client: () => client, disabled: true });
|
|
138
|
+
const fetcher = vi.fn(async () => 1);
|
|
139
|
+
await cache.get("k", fetcher);
|
|
140
|
+
await cache.get("k", fetcher);
|
|
141
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
142
|
+
expect(client.get).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("cacheKey стабилен к порядку полей и игнорирует undefined", () => {
|
|
146
|
+
expect(cacheKey("geo.city", { slug: "tbilisi", page: 1 })).toBe(
|
|
147
|
+
cacheKey("geo.city", { page: 1, slug: "tbilisi", x: undefined }),
|
|
148
|
+
);
|
|
149
|
+
expect(cacheKey("geo.cities")).toBe("geo.cities");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { Redis } from "ioredis";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Значение, прошедшее через JSON: Date становится строкой, undefined
|
|
5
|
+
* пропадает. Кэш отдаёт именно такую форму — ту же, что получает клиент tRPC.
|
|
6
|
+
*/
|
|
7
|
+
export type Jsonified<T> = T extends Date
|
|
8
|
+
? string
|
|
9
|
+
: T extends (infer U)[]
|
|
10
|
+
? Jsonified<U>[]
|
|
11
|
+
: T extends object
|
|
12
|
+
? { [K in keyof T]: Jsonified<T[K]> }
|
|
13
|
+
: T;
|
|
14
|
+
|
|
15
|
+
export type CacheOptions = {
|
|
16
|
+
/** время жизни в секундах (по умолчанию defaultTtl конструктора) */
|
|
17
|
+
ttl?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Теги зависимости: invalidate(tag) удаляет все ключи, записанные с
|
|
20
|
+
* этим тегом. Теги живут в Redis, поэтому инвалидация видна всем
|
|
21
|
+
* инстансам приложения, а не только тому, кто писал.
|
|
22
|
+
*/
|
|
23
|
+
tags?: string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type QuarkCacheConfig = {
|
|
27
|
+
/** клиент берётся лениво: плагин redis может быть ещё не готов */
|
|
28
|
+
client: () => Redis;
|
|
29
|
+
/** префикс ключей в Redis */
|
|
30
|
+
prefix?: string;
|
|
31
|
+
/** TTL по умолчанию, сек */
|
|
32
|
+
defaultTtl?: number;
|
|
33
|
+
/** выключатель: при true всегда идёт к источнику (dev, тесты) */
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
/** куда писать о сбоях Redis (по умолчанию console.error) */
|
|
36
|
+
onError?: (error: unknown) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Минимальный набор команд ioredis, которым пользуется кэш: так тесты
|
|
40
|
+
// подсовывают фейковый клиент, а тип не тянет весь Redis.
|
|
41
|
+
type RedisLike = Pick<
|
|
42
|
+
Redis,
|
|
43
|
+
"get" | "set" | "del" | "sadd" | "smembers" | "expire" | "scan"
|
|
44
|
+
>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read-through кэш поверх Redis (идея UglyCache из parkmobile).
|
|
48
|
+
*
|
|
49
|
+
* `get(key, fetcher, { ttl, tags })` отдаёт значение из Redis либо вызывает
|
|
50
|
+
* fetcher, кладёт результат и привязывает ключ к тегам. Параллельные
|
|
51
|
+
* вызовы одного ключа в одном процессе делят один fetcher. Сбой Redis не
|
|
52
|
+
* валит запрос: значение берётся из источника, ошибка уходит в onError.
|
|
53
|
+
*/
|
|
54
|
+
export class QuarkCache {
|
|
55
|
+
private readonly client: () => RedisLike;
|
|
56
|
+
private readonly prefix: string;
|
|
57
|
+
private readonly defaultTtl: number;
|
|
58
|
+
private readonly disabled: boolean;
|
|
59
|
+
private readonly onError: (error: unknown) => void;
|
|
60
|
+
// ключ → промис в полёте: дедупликация одновременных промахов
|
|
61
|
+
private readonly inflight = new Map<string, Promise<unknown>>();
|
|
62
|
+
|
|
63
|
+
constructor({
|
|
64
|
+
client,
|
|
65
|
+
prefix = "quark:cache:",
|
|
66
|
+
defaultTtl = 600,
|
|
67
|
+
disabled = false,
|
|
68
|
+
onError = (error) => console.error("[cache]", error),
|
|
69
|
+
}: QuarkCacheConfig) {
|
|
70
|
+
this.client = client as () => RedisLike;
|
|
71
|
+
this.prefix = prefix;
|
|
72
|
+
this.defaultTtl = defaultTtl;
|
|
73
|
+
this.disabled = disabled;
|
|
74
|
+
this.onError = onError;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private dataKey(key: string) {
|
|
78
|
+
return `${this.prefix}${key}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private tagKey(tag: string) {
|
|
82
|
+
return `${this.prefix}tag:${tag}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Значение по ключу или undefined (промах, выключенный кэш, сбой Redis). */
|
|
86
|
+
async peek<T>(key: string): Promise<Jsonified<T> | undefined> {
|
|
87
|
+
if (this.disabled) return undefined;
|
|
88
|
+
try {
|
|
89
|
+
const raw = await this.client().get(this.dataKey(key));
|
|
90
|
+
return raw === null ? undefined : (JSON.parse(raw) as Jsonified<T>);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.onError(error);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Кладёт значение и привязывает ключ к тегам. */
|
|
98
|
+
async set<T>(
|
|
99
|
+
key: string,
|
|
100
|
+
value: T,
|
|
101
|
+
{ ttl = this.defaultTtl, tags = [] }: CacheOptions = {},
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
if (this.disabled) return;
|
|
104
|
+
try {
|
|
105
|
+
const client = this.client();
|
|
106
|
+
const dataKey = this.dataKey(key);
|
|
107
|
+
await client.set(dataKey, JSON.stringify(value ?? null), "EX", ttl);
|
|
108
|
+
for (const tag of tags) {
|
|
109
|
+
const tagKey = this.tagKey(tag);
|
|
110
|
+
await client.sadd(tagKey, dataKey);
|
|
111
|
+
// набор тега живёт не меньше самого длинного значения в нём,
|
|
112
|
+
// иначе инвалидация потеряла бы ещё живые ключи
|
|
113
|
+
await client.expire(tagKey, Math.max(ttl, this.defaultTtl) * 2);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.onError(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read-through: значение из кэша либо из fetcher (с записью в кэш).
|
|
122
|
+
* Результат — JSON-форма значения, даже при промахе: иначе первый
|
|
123
|
+
* ответ (с Date) отличался бы от последующих (со строками).
|
|
124
|
+
*/
|
|
125
|
+
async get<T>(
|
|
126
|
+
key: string,
|
|
127
|
+
fetcher: () => Promise<T> | T,
|
|
128
|
+
options: CacheOptions = {},
|
|
129
|
+
): Promise<Jsonified<T>> {
|
|
130
|
+
const cached = await this.peek<T>(key);
|
|
131
|
+
if (cached !== undefined) return cached;
|
|
132
|
+
|
|
133
|
+
const pending = this.inflight.get(key);
|
|
134
|
+
if (pending) return pending as Promise<Jsonified<T>>;
|
|
135
|
+
|
|
136
|
+
const promise = (async () => {
|
|
137
|
+
const value = await fetcher();
|
|
138
|
+
await this.set(key, value, options);
|
|
139
|
+
return JSON.parse(JSON.stringify(value ?? null)) as Jsonified<T>;
|
|
140
|
+
})();
|
|
141
|
+
this.inflight.set(key, promise);
|
|
142
|
+
try {
|
|
143
|
+
return await promise;
|
|
144
|
+
} finally {
|
|
145
|
+
this.inflight.delete(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Удаляет ключ (без тегов — они зачистятся при инвалидации или по TTL). */
|
|
150
|
+
async delete(key: string): Promise<void> {
|
|
151
|
+
if (this.disabled) return;
|
|
152
|
+
try {
|
|
153
|
+
await this.client().del(this.dataKey(key));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.onError(error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Удаляет все ключи, записанные с любым из тегов. Возвращает число удалённых. */
|
|
160
|
+
async invalidate(...tags: string[]): Promise<number> {
|
|
161
|
+
if (this.disabled || tags.length === 0) return 0;
|
|
162
|
+
try {
|
|
163
|
+
const client = this.client();
|
|
164
|
+
let removed = 0;
|
|
165
|
+
for (const tag of tags) {
|
|
166
|
+
const tagKey = this.tagKey(tag);
|
|
167
|
+
const keys = await client.smembers(tagKey);
|
|
168
|
+
if (keys.length) removed += await client.del(...keys);
|
|
169
|
+
await client.del(tagKey);
|
|
170
|
+
}
|
|
171
|
+
return removed;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
this.onError(error);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Полная очистка кэша (все ключи с префиксом), через SCAN — без блокировки Redis. */
|
|
179
|
+
async clear(): Promise<number> {
|
|
180
|
+
if (this.disabled) return 0;
|
|
181
|
+
try {
|
|
182
|
+
const client = this.client();
|
|
183
|
+
let cursor = "0";
|
|
184
|
+
let removed = 0;
|
|
185
|
+
do {
|
|
186
|
+
const [next, keys] = await client.scan(
|
|
187
|
+
cursor,
|
|
188
|
+
"MATCH",
|
|
189
|
+
`${this.prefix}*`,
|
|
190
|
+
"COUNT",
|
|
191
|
+
500,
|
|
192
|
+
);
|
|
193
|
+
cursor = next;
|
|
194
|
+
if (keys.length) removed += await client.del(...keys);
|
|
195
|
+
} while (cursor !== "0");
|
|
196
|
+
return removed;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
this.onError(error);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Ключ кэша из имени и аргументов: стабильная сериализация с сортировкой полей. */
|
|
205
|
+
export function cacheKey(name: string, args?: unknown): string {
|
|
206
|
+
if (args === undefined) return name;
|
|
207
|
+
const stable = (value: unknown): unknown =>
|
|
208
|
+
Array.isArray(value)
|
|
209
|
+
? value.map(stable)
|
|
210
|
+
: value && typeof value === "object"
|
|
211
|
+
? Object.fromEntries(
|
|
212
|
+
Object.keys(value as Record<string, unknown>)
|
|
213
|
+
.sort()
|
|
214
|
+
.filter(
|
|
215
|
+
(k) =>
|
|
216
|
+
(value as Record<string, unknown>)[k] !==
|
|
217
|
+
undefined,
|
|
218
|
+
)
|
|
219
|
+
.map((k) => [
|
|
220
|
+
k,
|
|
221
|
+
stable((value as Record<string, unknown>)[k]),
|
|
222
|
+
]),
|
|
223
|
+
)
|
|
224
|
+
: value;
|
|
225
|
+
return `${name}:${JSON.stringify(stable(args))}`;
|
|
226
|
+
}
|
package/src/global.d.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { QuarkPlugin } from "@quark-fw/plugin";
|
|
2
|
+
import {} from "@quark-fw/plugin-redis/src/global.js";
|
|
3
|
+
import {} from "@quark-fw/cache/src/global.js";
|
|
4
|
+
import { QuarkCache } from "./QuarkCache.js";
|
|
5
|
+
|
|
6
|
+
export { QuarkCache, cacheKey } from "./QuarkCache.js";
|
|
7
|
+
export type {
|
|
8
|
+
CacheOptions,
|
|
9
|
+
Jsonified,
|
|
10
|
+
QuarkCacheConfig,
|
|
11
|
+
} from "./QuarkCache.js";
|
|
12
|
+
|
|
13
|
+
// Плагин: quark.cache поверх клиента plugin-redis. Параметры — из окружения:
|
|
14
|
+
// CACHE_PREFIX (ключи), CACHE_TTL (сек), CACHE_DISABLED=1 (всегда к источнику).
|
|
15
|
+
await quark.registerPlugin({
|
|
16
|
+
name: "cache",
|
|
17
|
+
dependsOn: ["redis"],
|
|
18
|
+
extend(quark) {
|
|
19
|
+
quark.cache = new QuarkCache({
|
|
20
|
+
client: () => quark.redis.client.value,
|
|
21
|
+
prefix: process.env.CACHE_PREFIX || "quark:cache:",
|
|
22
|
+
defaultTtl: Number(process.env.CACHE_TTL) || 600,
|
|
23
|
+
disabled: process.env.CACHE_DISABLED === "1",
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
} satisfies QuarkPlugin);
|