@warlock.js/cache 4.0.171 → 4.1.1
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 +85 -0
- package/cjs/index.cjs +4088 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/cache-manager.d.mts +314 -0
- package/esm/cache-manager.d.mts.map +1 -0
- package/esm/cache-manager.mjs +486 -0
- package/esm/cache-manager.mjs.map +1 -0
- package/esm/cached/auto-key.d.mts +25 -0
- package/esm/cached/auto-key.d.mts.map +1 -0
- package/esm/cached/auto-key.mjs +55 -0
- package/esm/cached/auto-key.mjs.map +1 -0
- package/esm/cached/cached.d.mts +54 -0
- package/esm/cached/cached.d.mts.map +1 -0
- package/esm/cached/cached.mjs +25 -0
- package/esm/cached/cached.mjs.map +1 -0
- package/esm/cached/index.d.mts +3 -0
- package/esm/cached/index.mjs +5 -0
- package/esm/cached/normalize-args.d.mts +51 -0
- package/esm/cached/normalize-args.d.mts.map +1 -0
- package/esm/cached/normalize-args.mjs +26 -0
- package/esm/cached/normalize-args.mjs.map +1 -0
- package/esm/drivers/base-cache-driver.d.mts +322 -0
- package/esm/drivers/base-cache-driver.d.mts.map +1 -0
- package/esm/drivers/base-cache-driver.mjs +522 -0
- package/esm/drivers/base-cache-driver.mjs.map +1 -0
- package/esm/drivers/file-cache-driver.d.mts +68 -0
- package/esm/drivers/file-cache-driver.d.mts.map +1 -0
- package/esm/drivers/file-cache-driver.mjs +174 -0
- package/esm/drivers/file-cache-driver.mjs.map +1 -0
- package/esm/drivers/index.d.mts +9 -0
- package/esm/drivers/index.mjs +11 -0
- package/esm/drivers/lru-memory-cache-driver.d.mts +136 -0
- package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -0
- package/esm/drivers/lru-memory-cache-driver.mjs +317 -0
- package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -0
- package/esm/drivers/memory-cache-driver.d.mts +112 -0
- package/esm/drivers/memory-cache-driver.d.mts.map +1 -0
- package/esm/drivers/memory-cache-driver.mjs +241 -0
- package/esm/drivers/memory-cache-driver.mjs.map +1 -0
- package/esm/drivers/memory-extended-cache-driver.d.mts +17 -0
- package/esm/drivers/memory-extended-cache-driver.d.mts.map +1 -0
- package/esm/drivers/memory-extended-cache-driver.mjs +34 -0
- package/esm/drivers/memory-extended-cache-driver.mjs.map +1 -0
- package/esm/drivers/mock-cache-driver.d.mts +137 -0
- package/esm/drivers/mock-cache-driver.d.mts.map +1 -0
- package/esm/drivers/mock-cache-driver.mjs +226 -0
- package/esm/drivers/mock-cache-driver.mjs.map +1 -0
- package/esm/drivers/null-cache-driver.d.mts +69 -0
- package/esm/drivers/null-cache-driver.d.mts.map +1 -0
- package/esm/drivers/null-cache-driver.mjs +92 -0
- package/esm/drivers/null-cache-driver.mjs.map +1 -0
- package/esm/drivers/pg-cache-driver.d.mts +148 -0
- package/esm/drivers/pg-cache-driver.d.mts.map +1 -0
- package/esm/drivers/pg-cache-driver.mjs +437 -0
- package/esm/drivers/pg-cache-driver.mjs.map +1 -0
- package/esm/drivers/redis-cache-driver.d.mts +86 -0
- package/esm/drivers/redis-cache-driver.d.mts.map +1 -0
- package/esm/drivers/redis-cache-driver.mjs +312 -0
- package/esm/drivers/redis-cache-driver.mjs.map +1 -0
- package/esm/index.d.mts +21 -0
- package/esm/index.mjs +24 -0
- package/esm/list/index.d.mts +1 -0
- package/esm/list/memory-cache-list.d.mts +77 -0
- package/esm/list/memory-cache-list.d.mts.map +1 -0
- package/esm/list/memory-cache-list.mjs +119 -0
- package/esm/list/memory-cache-list.mjs.map +1 -0
- package/esm/metrics.d.mts +118 -0
- package/esm/metrics.d.mts.map +1 -0
- package/esm/metrics.mjs +197 -0
- package/esm/metrics.mjs.map +1 -0
- package/esm/scoped-cache.d.mts +205 -0
- package/esm/scoped-cache.d.mts.map +1 -0
- package/esm/scoped-cache.mjs +274 -0
- package/esm/scoped-cache.mjs.map +1 -0
- package/esm/tagged-cache.d.mts +89 -0
- package/esm/tagged-cache.d.mts.map +1 -0
- package/esm/tagged-cache.mjs +147 -0
- package/esm/tagged-cache.mjs.map +1 -0
- package/esm/tagged-scoped-cache.d.mts +111 -0
- package/esm/tagged-scoped-cache.d.mts.map +1 -0
- package/esm/tagged-scoped-cache.mjs +142 -0
- package/esm/tagged-scoped-cache.mjs.map +1 -0
- package/esm/types.d.mts +1067 -0
- package/esm/types.d.mts.map +1 -0
- package/esm/types.mjs +62 -0
- package/esm/types.mjs.map +1 -0
- package/esm/utils.d.mts +161 -0
- package/esm/utils.d.mts.map +1 -0
- package/esm/utils.mjs +222 -0
- package/esm/utils.mjs.map +1 -0
- package/llms-full.txt +2071 -0
- package/llms.txt +28 -0
- package/package.json +53 -39
- package/skills/apply-cache-patterns/SKILL.md +97 -0
- package/skills/cache-basics/SKILL.md +121 -0
- package/skills/configure-pg-cache/SKILL.md +115 -0
- package/skills/configure-set-options/SKILL.md +96 -0
- package/skills/handle-cache-errors/SKILL.md +91 -0
- package/skills/observe-cache/SKILL.md +103 -0
- package/skills/overview/SKILL.md +69 -0
- package/skills/pick-cache-driver/SKILL.md +115 -0
- package/skills/test-cache-code/SKILL.md +219 -0
- package/skills/use-cache-atomic/SKILL.md +67 -0
- package/skills/use-cache-bulk/SKILL.md +57 -0
- package/skills/use-cache-list/SKILL.md +85 -0
- package/skills/use-cache-lock/SKILL.md +104 -0
- package/skills/use-cache-namespace/SKILL.md +88 -0
- package/skills/use-cache-similarity/SKILL.md +94 -0
- package/skills/use-cache-tags/SKILL.md +85 -0
- package/skills/use-cache-update-merge/SKILL.md +84 -0
- package/skills/use-cache-utils/SKILL.md +89 -0
- package/skills/use-cached-hof/SKILL.md +102 -0
- package/skills/use-swr/SKILL.md +104 -0
- package/cjs/cache-manager.d.ts +0 -163
- package/cjs/cache-manager.d.ts.map +0 -1
- package/cjs/cache-manager.js +0 -322
- package/cjs/cache-manager.js.map +0 -1
- package/cjs/drivers/base-cache-driver.d.ts +0 -152
- package/cjs/drivers/base-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/base-cache-driver.js +0 -321
- package/cjs/drivers/base-cache-driver.js.map +0 -1
- package/cjs/drivers/file-cache-driver.d.ts +0 -45
- package/cjs/drivers/file-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/file-cache-driver.js +0 -133
- package/cjs/drivers/file-cache-driver.js.map +0 -1
- package/cjs/drivers/index.d.ts +0 -8
- package/cjs/drivers/index.d.ts.map +0 -1
- package/cjs/drivers/lru-memory-cache-driver.d.ts +0 -98
- package/cjs/drivers/lru-memory-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/lru-memory-cache-driver.js +0 -252
- package/cjs/drivers/lru-memory-cache-driver.js.map +0 -1
- package/cjs/drivers/memory-cache-driver.d.ts +0 -82
- package/cjs/drivers/memory-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/memory-cache-driver.js +0 -218
- package/cjs/drivers/memory-cache-driver.js.map +0 -1
- package/cjs/drivers/memory-extended-cache-driver.d.ts +0 -13
- package/cjs/drivers/memory-extended-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/memory-extended-cache-driver.js +0 -25
- package/cjs/drivers/memory-extended-cache-driver.js.map +0 -1
- package/cjs/drivers/null-cache-driver.d.ts +0 -58
- package/cjs/drivers/null-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/null-cache-driver.js +0 -84
- package/cjs/drivers/null-cache-driver.js.map +0 -1
- package/cjs/drivers/redis-cache-driver.d.ts +0 -57
- package/cjs/drivers/redis-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/redis-cache-driver.js +0 -263
- package/cjs/drivers/redis-cache-driver.js.map +0 -1
- package/cjs/index.d.ts +0 -6
- package/cjs/index.d.ts.map +0 -1
- package/cjs/index.js +0 -1
- package/cjs/index.js.map +0 -1
- package/cjs/tagged-cache.d.ts +0 -77
- package/cjs/tagged-cache.d.ts.map +0 -1
- package/cjs/tagged-cache.js +0 -160
- package/cjs/tagged-cache.js.map +0 -1
- package/cjs/types.d.ts +0 -391
- package/cjs/types.d.ts.map +0 -1
- package/cjs/types.js +0 -36
- package/cjs/types.js.map +0 -1
- package/cjs/utils.d.ts +0 -50
- package/cjs/utils.d.ts.map +0 -1
- package/cjs/utils.js +0 -55
- package/cjs/utils.js.map +0 -1
- package/esm/cache-manager.d.ts +0 -163
- package/esm/cache-manager.d.ts.map +0 -1
- package/esm/cache-manager.js +0 -322
- package/esm/cache-manager.js.map +0 -1
- package/esm/drivers/base-cache-driver.d.ts +0 -152
- package/esm/drivers/base-cache-driver.d.ts.map +0 -1
- package/esm/drivers/base-cache-driver.js +0 -321
- package/esm/drivers/base-cache-driver.js.map +0 -1
- package/esm/drivers/file-cache-driver.d.ts +0 -45
- package/esm/drivers/file-cache-driver.d.ts.map +0 -1
- package/esm/drivers/file-cache-driver.js +0 -133
- package/esm/drivers/file-cache-driver.js.map +0 -1
- package/esm/drivers/index.d.ts +0 -8
- package/esm/drivers/index.d.ts.map +0 -1
- package/esm/drivers/lru-memory-cache-driver.d.ts +0 -98
- package/esm/drivers/lru-memory-cache-driver.d.ts.map +0 -1
- package/esm/drivers/lru-memory-cache-driver.js +0 -252
- package/esm/drivers/lru-memory-cache-driver.js.map +0 -1
- package/esm/drivers/memory-cache-driver.d.ts +0 -82
- package/esm/drivers/memory-cache-driver.d.ts.map +0 -1
- package/esm/drivers/memory-cache-driver.js +0 -218
- package/esm/drivers/memory-cache-driver.js.map +0 -1
- package/esm/drivers/memory-extended-cache-driver.d.ts +0 -13
- package/esm/drivers/memory-extended-cache-driver.d.ts.map +0 -1
- package/esm/drivers/memory-extended-cache-driver.js +0 -25
- package/esm/drivers/memory-extended-cache-driver.js.map +0 -1
- package/esm/drivers/null-cache-driver.d.ts +0 -58
- package/esm/drivers/null-cache-driver.d.ts.map +0 -1
- package/esm/drivers/null-cache-driver.js +0 -84
- package/esm/drivers/null-cache-driver.js.map +0 -1
- package/esm/drivers/redis-cache-driver.d.ts +0 -57
- package/esm/drivers/redis-cache-driver.d.ts.map +0 -1
- package/esm/drivers/redis-cache-driver.js +0 -263
- package/esm/drivers/redis-cache-driver.js.map +0 -1
- package/esm/index.d.ts +0 -6
- package/esm/index.d.ts.map +0 -1
- package/esm/index.js +0 -1
- package/esm/index.js.map +0 -1
- package/esm/tagged-cache.d.ts +0 -77
- package/esm/tagged-cache.d.ts.map +0 -1
- package/esm/tagged-cache.js +0 -160
- package/esm/tagged-cache.js.map +0 -1
- package/esm/types.d.ts +0 -391
- package/esm/types.d.ts.map +0 -1
- package/esm/types.js +0 -36
- package/esm/types.js.map +0 -1
- package/esm/utils.d.ts +0 -50
- package/esm/utils.d.ts.map +0 -1
- package/esm/utils.js +0 -55
- package/esm/utils.js.map +0 -1
package/llms-full.txt
ADDED
|
@@ -0,0 +1,2071 @@
|
|
|
1
|
+
# Warlock Cache — full skills
|
|
2
|
+
|
|
3
|
+
> Package: `@warlock.js/cache`
|
|
4
|
+
|
|
5
|
+
> Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/cache/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.
|
|
6
|
+
|
|
7
|
+
## apply-cache-patterns `@warlock.js/cache/apply-cache-patterns/SKILL.md`
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
name: apply-cache-patterns
|
|
11
|
+
description: 'Compose cache primitives into real-world patterns — remember() memoization, cross-node stampede protection via a distributed lock (onConflict: ''create''), negative caching, and per-tenant scoping. Triggers: `cache.remember`, `cache.set` with `onConflict: "create"`, `globalPrefix`; "memoize this function", "prevent cache stampede across nodes", "cache not-found results", "per-tenant cache scoping"; typical import `import { cache } from "@warlock.js/cache"`. Skip: counters — `@warlock.js/cache/use-cache-atomic/SKILL.md`; bulk get/set — `@warlock.js/cache/use-cache-bulk/SKILL.md`; TTL constants/utilities — `@warlock.js/cache/use-cache-utils/SKILL.md`; named lock wrapper — `@warlock.js/cache/use-cache-lock/SKILL.md`; SWR — `@warlock.js/cache/use-swr/SKILL.md`; competing libs `lru-cache`, `node-cache`, `keyv`.'
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Real-world caching patterns
|
|
15
|
+
|
|
16
|
+
Common shapes — the "general patterns" file. Specialized topics have dedicated skills: [`use-cache-tags`](@warlock.js/cache/use-cache-tags/SKILL.md), [`use-cache-namespace`](@warlock.js/cache/use-cache-namespace/SKILL.md), [`use-swr`](@warlock.js/cache/use-swr/SKILL.md), [`use-cache-lock`](@warlock.js/cache/use-cache-lock/SKILL.md), [`use-cache-list`](@warlock.js/cache/use-cache-list/SKILL.md).
|
|
17
|
+
|
|
18
|
+
## Memoize an expensive function — `remember`
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const user = await cache.remember(`user:${id}`, "1h", async () => {
|
|
22
|
+
return db.users.find(id); // runs only on cache miss
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- The callback runs once per miss.
|
|
27
|
+
- Concurrent callers for the same key share the in-flight promise (stampede protection) — within one Node process.
|
|
28
|
+
- `null` is the universal miss sentinel. `remember` short-circuits on a **truthy** cached value (`if (cachedValue) return cachedValue;`), so a stored `null` reads back as a miss and the callback **re-runs on every call** — you get no caching at all, plus a wasted write each time. To actually cache a "not found," store a truthy sentinel instead (see negative caching below).
|
|
29
|
+
|
|
30
|
+
## Cross-process stampede protection — distributed lock via `onConflict`
|
|
31
|
+
|
|
32
|
+
`remember`'s lock is per-process. For cross-node safety, acquire a short-lived distributed lock before doing expensive work:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const lockKey = `lock:build-report:${reportId}`;
|
|
36
|
+
const acquired = await cache.set(lockKey, process.pid, {
|
|
37
|
+
onConflict: "create",
|
|
38
|
+
ttl: "2m",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!acquired.wasSet) {
|
|
42
|
+
// another node is already building — wait or skip
|
|
43
|
+
return cache.get(`report:${reportId}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const report = await buildExpensiveReport(reportId);
|
|
48
|
+
await cache.set(`report:${reportId}`, report, "1h");
|
|
49
|
+
return report;
|
|
50
|
+
} finally {
|
|
51
|
+
await cache.remove(lockKey);
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This requires a driver with atomic `SET NX` — Redis is native, memory/LRU/file emulate (single-process only). For a higher-level wrapper that does the lock-and-release for you, see [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md).
|
|
56
|
+
|
|
57
|
+
## Negative caching
|
|
58
|
+
|
|
59
|
+
Cache "not found" results with a shorter TTL to avoid hammering the origin:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const user = await cache.remember(`user:${id}`, "5m", async () => {
|
|
63
|
+
const found = await db.users.find(id);
|
|
64
|
+
return found ?? { __miss: true };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (user?.__miss) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Don't return raw `null` inside `remember` to "cache the miss" — it won't. `remember`'s truthy guard treats a stored `null` as a miss, so the callback re-runs every time and the origin still gets hammered. The truthy `{ __miss: true }` sentinel is what actually skips the next call.
|
|
73
|
+
|
|
74
|
+
## Per-tenant caching
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// In cache config:
|
|
78
|
+
options: {
|
|
79
|
+
redis: {
|
|
80
|
+
url: "...",
|
|
81
|
+
globalPrefix: () => `tenant-${currentContext.tenantId}`,
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// At the call site — no tenancy awareness needed:
|
|
86
|
+
await cache.set("user:1", user, "1h");
|
|
87
|
+
// Actual key: "tenant-42.user.1"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Clear a tenant out:
|
|
91
|
+
```ts
|
|
92
|
+
await cache.removeNamespace(""); // when globalPrefix is set, flush scopes to it
|
|
93
|
+
// or
|
|
94
|
+
await cache.tags([`tenant-${tenantId}`]).invalidate();
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## See also
|
|
98
|
+
|
|
99
|
+
- [`@warlock.js/cache/use-cache-atomic/SKILL.md`](@warlock.js/cache/use-cache-atomic/SKILL.md) — `increment` / `decrement` counters
|
|
100
|
+
- [`@warlock.js/cache/use-cache-bulk/SKILL.md`](@warlock.js/cache/use-cache-bulk/SKILL.md) — `many` / `setMany`
|
|
101
|
+
- [`@warlock.js/cache/use-cache-utils/SKILL.md`](@warlock.js/cache/use-cache-utils/SKILL.md) — `CACHE_FOR` constants and TTL/key helpers
|
|
102
|
+
- [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md) — tag-based invalidation
|
|
103
|
+
- [`@warlock.js/cache/use-cache-namespace/SKILL.md`](@warlock.js/cache/use-cache-namespace/SKILL.md) — scoped handles and `removeNamespace`
|
|
104
|
+
- [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md) — stale-while-revalidate for slow upstreams
|
|
105
|
+
- [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md) — `cached()` HOF for declarative memoization
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
## cache-basics `@warlock.js/cache/cache-basics/SKILL.md`
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
name: cache-basics
|
|
112
|
+
description: 'Start with @warlock.js/cache — the cache singleton, primary ops (set / get / pull / remove / many / forever / increment / remember), TTL shapes, init flow. Triggers: `cache`, `cache.setCacheConfigurations`, `cache.init`, `cache.set`, `cache.get`, `cache.remove`, `cache.remember`, `cache.flush`; "start with warlock cache", "wire up cache at startup", "which cache skill do I need"; typical import `import { cache } from "@warlock.js/cache"`. Skip: driver choice — `@warlock.js/cache/pick-cache-driver/SKILL.md`; set options — `@warlock.js/cache/configure-set-options/SKILL.md`; competing libs `lru-cache`, `node-cache`, `keyv`; native `Map`.'
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
# Cache basics
|
|
116
|
+
|
|
117
|
+
Unified cache manager with 8 built-in drivers (memory / memoryExtended / LRU / file / null / redis / pg / mock), tag-based invalidation, list sub-API, atomic `update`/`merge`, similarity retrieval, scoped namespace handles, and a rich `set` options object. The same surface across drivers — switch via config, not call sites.
|
|
118
|
+
|
|
119
|
+
> This skill is the cache **map** — read it first, then load the specific skill for the task.
|
|
120
|
+
|
|
121
|
+
## Install
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
yarn add @warlock.js/cache
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Foundations
|
|
128
|
+
|
|
129
|
+
The 10 things that are true in every cache use:
|
|
130
|
+
|
|
131
|
+
1. **Public API is the `cache` singleton** (`import { cache } from "@warlock.js/cache"`). No `new CacheManager()` for consumers.
|
|
132
|
+
2. **Every data op runs against the currently selected driver.** Switch via `cache.use("name")` or use a per-call override: `cache.set(k, v, { driver: "redis" })`.
|
|
133
|
+
3. **Consumers never `await connect()` directly.** `cache.init()` does that once at startup after `cache.setCacheConfigurations(...)`. For drivers needing runtime-built options (e.g. `pg`'s `client: pg.Pool`), skip `init()` and call `cache.use("pg", { client: pool })`.
|
|
134
|
+
4. **TTL accepts three shapes at the call site**: `number` (seconds), `string` (`"1h"`, `"30m"`, `"7d"` — parsed via `ms`), or a full `CacheSetOptions` object. See [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md).
|
|
135
|
+
5. **`update` and `merge` throw `CacheUnsupportedError` on the file driver.** Use memory or redis for atomic mutation. See [`@warlock.js/cache/use-cache-update-merge/SKILL.md`](@warlock.js/cache/use-cache-update-merge/SKILL.md).
|
|
136
|
+
6. **The value you read is a deep clone.** `structuredClone` protects the cache from accidental mutation of returned objects.
|
|
137
|
+
7. **`remember()` is stampede-safe within a single process.** Cross-process safety requires `onConflict: "create"` plus TTL (Redis-native). For slow upstreams where slightly-stale data is acceptable, prefer `cache.swr(...)` — see [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md).
|
|
138
|
+
8. **`cache.metrics()` returns a running snapshot** — counters, hit rate, latency percentiles, per-driver breakdowns. Lazy: collector attaches on first call so apps that never read metrics pay zero cost.
|
|
139
|
+
9. **`cache.namespace(prefix, options?)` returns a scoped handle** — every key auto-prefixed, scope-level `ttl` / `tags` defaults. See [`@warlock.js/cache/use-cache-namespace/SKILL.md`](@warlock.js/cache/use-cache-namespace/SKILL.md).
|
|
140
|
+
10. **Similarity retrieval** lives on the same driver contract — `set(k, v, { vector })` indexes the entry; `cache.similar(vec, ...)` returns nearest hits. See [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md).
|
|
141
|
+
|
|
142
|
+
## Minimal startup
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import {
|
|
146
|
+
cache,
|
|
147
|
+
MemoryCacheDriver,
|
|
148
|
+
RedisCacheDriver,
|
|
149
|
+
type CacheConfigurations,
|
|
150
|
+
} from "@warlock.js/cache";
|
|
151
|
+
|
|
152
|
+
const config: CacheConfigurations = {
|
|
153
|
+
default: "redis",
|
|
154
|
+
logging: false,
|
|
155
|
+
drivers: {
|
|
156
|
+
memory: MemoryCacheDriver,
|
|
157
|
+
redis: RedisCacheDriver,
|
|
158
|
+
},
|
|
159
|
+
options: {
|
|
160
|
+
memory: { ttl: "1h" },
|
|
161
|
+
redis: { url: "redis://localhost:6379", ttl: "7d" },
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
cache.setCacheConfigurations(config);
|
|
166
|
+
await cache.init();
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Primary ops
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// Set + get
|
|
173
|
+
await cache.set("user.1", user, "1h");
|
|
174
|
+
const cached = await cache.get<User>("user.1"); // User | null
|
|
175
|
+
|
|
176
|
+
// Presence + read-and-delete
|
|
177
|
+
const exists = await cache.has("user.1"); // boolean
|
|
178
|
+
const taken = await cache.pull<User>("user.1"); // returns then removes
|
|
179
|
+
|
|
180
|
+
// Remove + flush
|
|
181
|
+
await cache.remove("user.1");
|
|
182
|
+
await cache.flush(); // wipe everything (current driver)
|
|
183
|
+
|
|
184
|
+
// Many at once — array positionally aligned with the keys (null for misses)
|
|
185
|
+
const [u1, u2, u3] = await cache.many(["user.1", "user.2", "user.3"]);
|
|
186
|
+
// → (User | null)[]
|
|
187
|
+
|
|
188
|
+
// No-TTL writes
|
|
189
|
+
await cache.forever("config.version", "1.2.3");
|
|
190
|
+
|
|
191
|
+
// Counters
|
|
192
|
+
await cache.increment("post.42.views"); // +1, returns new value
|
|
193
|
+
await cache.increment("post.42.views", 10); // +10
|
|
194
|
+
await cache.decrement("inventory.sku-x"); // -1
|
|
195
|
+
|
|
196
|
+
// Memoize an expensive function
|
|
197
|
+
const user = await cache.remember("user.1", "1h", async () => db.users.find(1));
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Pick a skill
|
|
201
|
+
|
|
202
|
+
| If the task is about… | Load |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| Choosing a driver, configuring it, or understanding what each one does best | [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) |
|
|
205
|
+
| The `set` options object (`ttl`, `expiresAt`, `tags`, `onConflict`, `driver`, `vector`) | [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) |
|
|
206
|
+
| Memoization with `remember()`, counters, negative caching, per-tenant scoping, TTL constants | [`@warlock.js/cache/apply-cache-patterns/SKILL.md`](@warlock.js/cache/apply-cache-patterns/SKILL.md) |
|
|
207
|
+
| Scoped handles via `cache.namespace(prefix, options?)` | [`@warlock.js/cache/use-cache-namespace/SKILL.md`](@warlock.js/cache/use-cache-namespace/SKILL.md) |
|
|
208
|
+
| Tag-based invalidation — `cache.tags([...]).invalidate()` | [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md) |
|
|
209
|
+
| Stale-while-revalidate — `cache.swr(...)` | [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md) |
|
|
210
|
+
| Wrapping a function with `cached()` — HOF memoization with `.invalidate()` | [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md) |
|
|
211
|
+
| Distributed locks — `cache.lock(key, ttl, fn)` with auto-release | [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md) |
|
|
212
|
+
| Queues, recent-N buffers, `push`/`shift`/`trim` — the list sub-API | [`@warlock.js/cache/use-cache-list/SKILL.md`](@warlock.js/cache/use-cache-list/SKILL.md) |
|
|
213
|
+
| Atomic read-modify-write via `update()` and `merge()` | [`@warlock.js/cache/use-cache-update-merge/SKILL.md`](@warlock.js/cache/use-cache-update-merge/SKILL.md) |
|
|
214
|
+
| Similarity retrieval — `set({ vector })` + `cache.similar(...)` | [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) |
|
|
215
|
+
| Postgres driver setup (KV-only or with pgvector) | [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md) |
|
|
216
|
+
| `cache.metrics()` aggregate snapshot + event bus for per-event reactions | [`@warlock.js/cache/observe-cache/SKILL.md`](@warlock.js/cache/observe-cache/SKILL.md) |
|
|
217
|
+
| Error classes (`CacheConfigurationError`, `CacheUnsupportedError`, etc.) | [`@warlock.js/cache/handle-cache-errors/SKILL.md`](@warlock.js/cache/handle-cache-errors/SKILL.md) |
|
|
218
|
+
| Tests that touch cache code paths — `MockCacheDriver`, `MemoryCacheDriver` | [`@warlock.js/cache/test-cache-code/SKILL.md`](@warlock.js/cache/test-cache-code/SKILL.md) |
|
|
219
|
+
|
|
220
|
+
## Things NOT to do
|
|
221
|
+
|
|
222
|
+
- Don't call `new RedisCacheDriver()` directly in app code — register it in the configuration and let the manager load it.
|
|
223
|
+
- Don't store un-serializable values (functions, symbols, class instances with methods) on the `redis` or `file` drivers — they JSON-roundtrip.
|
|
224
|
+
- Don't rely on `remember()` for cross-process stampede protection. It only serializes within one Node process.
|
|
225
|
+
- Don't mix `ttl` and `expiresAt` in the same `set` call — it throws `CacheConfigurationError`.
|
|
226
|
+
- Don't call `update()` / `merge()` on the file driver — it throws.
|
|
227
|
+
- Don't assume `setNX` is available on every driver — prefer `onConflict: "create"` which works everywhere.
|
|
228
|
+
- Don't run `cache.similar()` against memory drivers in production with large datasets — O(N) brute force.
|
|
229
|
+
- Don't auto-migrate the `pg` table from app code. The driver exposes `driver.schema()` returning DDL.
|
|
230
|
+
- Don't switch embedders without re-embedding the entire vector index.
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
## configure-pg-cache `@warlock.js/cache/configure-pg-cache/SKILL.md`
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
name: configure-pg-cache
|
|
237
|
+
description: 'Postgres cache driver setup — KV-only mode (default) or pgvector mode (opt in via options.pg.vector). Caller owns the pg.Pool, driver exposes driver.schema() for one-time DDL. Triggers: `PgCacheDriver`, `driver.schema`, `options.pg.vector`, `pg.Pool`, `hnsw`, `ivfflat`; "use Postgres as cache backend", "set up pgvector semantic cache", "DDL for warlock cache table"; typical import `import { cache, PgCacheDriver } from "@warlock.js/cache"`. Skip: cross-driver similarity API — `@warlock.js/cache/use-cache-similarity/SKILL.md`; driver picker — `@warlock.js/cache/pick-cache-driver/SKILL.md`; competing libs `pg-mem`; raw `pg` / `node-postgres`.'
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
# `pg` cache driver — Postgres setup
|
|
241
|
+
|
|
242
|
+
Persistent cache backed by your existing Postgres pool. Two modes: **KV-only** (default) or **pgvector** (opt in via `options.pg.vector`). Same driver, same API — flip a config flag.
|
|
243
|
+
|
|
244
|
+
## Always-true facts
|
|
245
|
+
|
|
246
|
+
1. **Caller owns the connection.** Pass an already-built `pg.Pool` (or `Client`) via `options.pg.client`. The driver never closes it on `cache.disconnect()` — your pool stays usable everywhere else.
|
|
247
|
+
2. **`pg` is an optional peer dep.** Lazy-loaded; install only if you use this driver.
|
|
248
|
+
3. **No auto-migration.** Driver exposes `driver.schema()` returning a DDL string — caller runs it via their own migration tool.
|
|
249
|
+
4. **Table name is regex-validated** (`[A-Za-z_][A-Za-z0-9_]*`) before DDL interpolation. No SQL injection via misconfiguration.
|
|
250
|
+
5. **TTL is lazy on read.** `SELECT ... WHERE expires_at IS NULL OR expires_at > now()`. Expired rows aren't auto-deleted unless you GC them yourself.
|
|
251
|
+
6. **`onConflict` is race-safe at the SQL layer:**
|
|
252
|
+
- `create` → `INSERT ... ON CONFLICT DO UPDATE WHERE expires_at < now() RETURNING value` (reclaims expired rows; blocks live ones).
|
|
253
|
+
- `update` → `UPDATE ... WHERE expires_at IS NULL OR expires_at > now() RETURNING value`.
|
|
254
|
+
- `upsert` → unconditional `INSERT ... ON CONFLICT DO UPDATE`.
|
|
255
|
+
7. **`stale_at TIMESTAMPTZ` column** powers [stale-while-revalidate](@warlock.js/cache/use-swr/SKILL.md) — `cache.swr(...)` populates it on writes, plain `set()` leaves it null (always-fresh). Provision via `driver.schema()` like any other column.
|
|
256
|
+
8. **pgvector requires `CREATE EXTENSION vector;` once on the database.** Lazy probe on first vector op throws `CacheConfigurationError` if missing; result is cached.
|
|
257
|
+
9. **Vectors are passed as text literals** (`'[1,2,3]'::vector`). No binary protocol dependency — works against any pg client.
|
|
258
|
+
|
|
259
|
+
## Configuration
|
|
260
|
+
|
|
261
|
+
### KV-only
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { Pool } from "pg";
|
|
265
|
+
import { cache, PgCacheDriver } from "@warlock.js/cache";
|
|
266
|
+
|
|
267
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
268
|
+
|
|
269
|
+
cache.setCacheConfigurations({
|
|
270
|
+
default: "pg",
|
|
271
|
+
drivers: { pg: PgCacheDriver },
|
|
272
|
+
options: {
|
|
273
|
+
pg: {
|
|
274
|
+
client: pool,
|
|
275
|
+
table: "warlock_cache", // optional, default
|
|
276
|
+
ttl: "1h", // optional default
|
|
277
|
+
globalPrefix: "prod-app",
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
await cache.init();
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
When the pool isn't available at config time (lazy bootstrap, per-tenant pools, test swapping), drop `client` from the static block and inject it at use-time: `cache.use("pg", { client: pool })`. Runtime options merge over static per-key, runtime wins. Re-calling with new options throws — register a second driver name for a second config.
|
|
285
|
+
|
|
286
|
+
### pgvector mode
|
|
287
|
+
|
|
288
|
+
Same driver — add the `vector` block:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
options: {
|
|
292
|
+
pg: {
|
|
293
|
+
client: pool,
|
|
294
|
+
vector: {
|
|
295
|
+
dimensions: 1536, // must match your embedder
|
|
296
|
+
index: "hnsw", // or "ivfflat"; default "hnsw"
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## One-time schema setup
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
await pool.query(driver.schema());
|
|
306
|
+
// CREATE TABLE IF NOT EXISTS warlock_cache (
|
|
307
|
+
// key TEXT PRIMARY KEY,
|
|
308
|
+
// value JSONB NOT NULL,
|
|
309
|
+
// expires_at TIMESTAMPTZ,
|
|
310
|
+
// stale_at TIMESTAMPTZ,
|
|
311
|
+
// tags TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
|
|
312
|
+
// embedding VECTOR(1536) -- only when vector config is set
|
|
313
|
+
// );
|
|
314
|
+
// CREATE INDEX IF NOT EXISTS idx_warlock_cache_expires_at ...
|
|
315
|
+
// CREATE INDEX IF NOT EXISTS idx_warlock_cache_tags ... USING GIN (tags);
|
|
316
|
+
// CREATE INDEX IF NOT EXISTS idx_warlock_cache_embedding ... USING hnsw (embedding vector_cosine_ops);
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Pipe this through whichever migration tool you use (Knex, Prisma, plain SQL files, Atlas).
|
|
320
|
+
|
|
321
|
+
## Index strategies (pgvector)
|
|
322
|
+
|
|
323
|
+
- `hnsw` (default) — faster query, slower build, larger on disk. The right default.
|
|
324
|
+
- `ivfflat` — faster build, slightly slower query. Useful for bulk ingest then static reads.
|
|
325
|
+
|
|
326
|
+
Switching strategies requires rebuilding the index.
|
|
327
|
+
|
|
328
|
+
## Errors you'll surface
|
|
329
|
+
|
|
330
|
+
- `CacheConfigurationError: requires a 'client' option` — forgot to pass the pool.
|
|
331
|
+
- `CacheConfigurationError: invalid table name` — non-`[A-Za-z_][A-Za-z0-9_]*` characters in `options.pg.table`.
|
|
332
|
+
- `CacheConfigurationError: pgvector extension not installed` — run `CREATE EXTENSION vector;` once on the DB, or remove the `vector` block.
|
|
333
|
+
- `CacheConfigurationError: vector dimension mismatch` — input vector length ≠ configured dimensions. Embedder probably changed.
|
|
334
|
+
- `CacheUnsupportedError: similarity retrieval requires the 'vector' config block` — KV-only mode; add `options.pg.vector`.
|
|
335
|
+
|
|
336
|
+
See [`@warlock.js/cache/handle-cache-errors/SKILL.md`](@warlock.js/cache/handle-cache-errors/SKILL.md) for the full error class hierarchy.
|
|
337
|
+
|
|
338
|
+
## Things NOT to do
|
|
339
|
+
|
|
340
|
+
- Don't auto-run `driver.schema()` from app code — it's a one-time migration. Run it through your migration pipeline.
|
|
341
|
+
- Don't share the driver's pool with the connection-eager `pg.Client` form for long-running apps — use `pg.Pool`.
|
|
342
|
+
- Don't expect the driver to close your pool. `cache.disconnect()` deliberately leaves it open. Close the pool yourself when shutting down.
|
|
343
|
+
- Don't switch embedders without re-embedding the index. Vectors aren't portable across models.
|
|
344
|
+
- Don't put the `pg` driver behind a connection-string the cache itself manages — pass the pool you already built for the rest of the app.
|
|
345
|
+
|
|
346
|
+
## Related
|
|
347
|
+
|
|
348
|
+
- [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — the `similar()` API across all drivers
|
|
349
|
+
- [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — comparing pg with memory / redis / file
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
## configure-set-options `@warlock.js/cache/configure-set-options/SKILL.md`
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
name: configure-set-options
|
|
356
|
+
description: 'Configure cache.set''s third argument — ttl, expiresAt, tags, onConflict (create / update / upsert), driver, vector. Triggers: `cache.set`, `ttl`, `expiresAt`, `tags`, `onConflict`, `driver`, `vector`, `CacheSetResult`, `wasSet`; "set a key only if missing", "set with absolute deadline", "attach tags inline", "route one cache call to redis"; typical import `import { cache } from "@warlock.js/cache"`. Skip: tag fluent API — `@warlock.js/cache/use-cache-tags/SKILL.md`; vector queries — `@warlock.js/cache/use-cache-similarity/SKILL.md`; competing libs `keyv`, `ioredis`.'
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
# The `set` options object
|
|
360
|
+
|
|
361
|
+
`cache.set(key, value, ttlOrOptions?)` — the 3rd argument accepts three shapes.
|
|
362
|
+
|
|
363
|
+
## The three shapes
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
// 1. Number — seconds
|
|
367
|
+
await cache.set("name", "Jane", 600);
|
|
368
|
+
|
|
369
|
+
// 2. String — human-readable duration, parsed via `ms`
|
|
370
|
+
await cache.set("name", "Jane", "10m"); // "1s", "30m", "1h", "7d", "2 weeks"
|
|
371
|
+
|
|
372
|
+
// 3. Options object
|
|
373
|
+
await cache.set("user:1", user, {
|
|
374
|
+
ttl: "1h",
|
|
375
|
+
expiresAt: new Date("2026-12-31"),
|
|
376
|
+
tags: ["users"],
|
|
377
|
+
onConflict: "create",
|
|
378
|
+
driver: "redis",
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Option keys
|
|
383
|
+
|
|
384
|
+
| Key | Type | Notes |
|
|
385
|
+
| --- | --- | --- |
|
|
386
|
+
| `ttl` | `number \| string` | Relative expiry. Mutually exclusive with `expiresAt`. |
|
|
387
|
+
| `expiresAt` | `number \| Date` | Absolute deadline (epoch ms or Date). Must be in the future. Mutually exclusive with `ttl`. |
|
|
388
|
+
| `tags` | `string[]` | Inline equivalent of `cache.tags([...]).set(...)`. See [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md). |
|
|
389
|
+
| `onConflict` | `"create" \| "update" \| "upsert"` | See below. Default `"upsert"`. |
|
|
390
|
+
| `driver` | `string` | Per-call driver override by registered name. |
|
|
391
|
+
| `vector` | `number[]` | Embedding indexed alongside the entry for [`cache.similar()`](@warlock.js/cache/use-cache-similarity/SKILL.md). Drivers without similarity support throw `CacheUnsupportedError`. |
|
|
392
|
+
|
|
393
|
+
## `onConflict` policies
|
|
394
|
+
|
|
395
|
+
Self-documenting enum; Redis maps these to `NX` / `XX` natively, others emulate.
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
// create — set only if key is missing
|
|
399
|
+
const result = await cache.set("lock:jobs:import", workerId, {
|
|
400
|
+
onConflict: "create",
|
|
401
|
+
ttl: "5m",
|
|
402
|
+
});
|
|
403
|
+
// result: { wasSet: true, existing: null } on acquire
|
|
404
|
+
// { wasSet: false, existing: <prior workerId> } on conflict — someone else holds the lock
|
|
405
|
+
|
|
406
|
+
if (!result.wasSet) {
|
|
407
|
+
// another worker is already running; abort.
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// update — set only if key exists (don't resurrect expired sessions)
|
|
411
|
+
await cache.set("session:abc", session, { onConflict: "update" });
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Conditional writes (`"create"` / `"update"`) return a `CacheSetResult`; unconditional `"upsert"` returns the value or driver instance as before.
|
|
415
|
+
|
|
416
|
+
## Mutually-exclusive validations
|
|
417
|
+
|
|
418
|
+
Both of these throw `CacheConfigurationError`:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
await cache.set("k", v, { ttl: "1h", expiresAt: Date.now() + 1000 }); // both set
|
|
422
|
+
await cache.set("k", v, { expiresAt: Date.now() - 1000 }); // past deadline
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## Inline tags vs `cache.tags([...]).set(...)`
|
|
426
|
+
|
|
427
|
+
Both work; inline is terser when you're writing one value under known tags:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
// Inline
|
|
431
|
+
await cache.set("user:1", user, { tags: ["users", "tenant-42"] });
|
|
432
|
+
|
|
433
|
+
// Fluent (useful when you already have a tagged instance)
|
|
434
|
+
const users = cache.tags(["users"]);
|
|
435
|
+
await users.set("user:1", user);
|
|
436
|
+
await users.invalidate(); // drops every key tagged "users"
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Inline tag semantics are **additive**, never replace. A subsequent `set("user:1", ...)` with no `tags` leaves previous associations intact (the tag index still points to the key), and a `set(..., { tags: [...] })` only appends the key to those tags' index entries — it never removes the key from tags it was bound to earlier. To drop stale bindings, invalidate the old tag explicitly via `cache.tags([...]).invalidate()`.
|
|
440
|
+
|
|
441
|
+
## Back-compat note
|
|
442
|
+
|
|
443
|
+
Every call site using the old positional-TTL shape keeps working:
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
await cache.set("k", v); // no TTL — driver default
|
|
447
|
+
await cache.set("k", v, 3600); // seconds
|
|
448
|
+
await cache.set("k", v, undefined); // same as no TTL
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
## handle-cache-errors `@warlock.js/cache/handle-cache-errors/SKILL.md`
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
name: handle-cache-errors
|
|
456
|
+
description: 'Cache error classes — CacheError base, CacheConfigurationError, CacheConnectionError, CacheDriverNotInitializedError, CacheUnsupportedError, CacheConcurrencyError. Triggers: `CacheError`, `CacheConfigurationError`, `CacheConnectionError`, `CacheDriverNotInitializedError`, `CacheUnsupportedError`, `CacheConcurrencyError`; "catch cache errors at the boundary", "degrade when update or merge throws", "what does CacheUnsupportedError mean", "fall back when redis is down"; typical import `import { CacheError, CacheConfigurationError, CacheUnsupportedError } from "@warlock.js/cache"`. Skip: choosing a supported driver — `@warlock.js/cache/pick-cache-driver/SKILL.md`; observing errors via events — `@warlock.js/cache/observe-cache/SKILL.md`; competing libs ignore — generic `Error` patterns.'
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
# Error classes
|
|
460
|
+
|
|
461
|
+
All cache errors extend `CacheError` which extends `Error`. Use `instanceof` to react selectively.
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
import {
|
|
465
|
+
CacheError,
|
|
466
|
+
CacheConfigurationError,
|
|
467
|
+
CacheConnectionError,
|
|
468
|
+
CacheDriverNotInitializedError,
|
|
469
|
+
CacheUnsupportedError,
|
|
470
|
+
CacheConcurrencyError,
|
|
471
|
+
} from "@warlock.js/cache";
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
| Class | When it's thrown | How to react |
|
|
475
|
+
| --- | --- | --- |
|
|
476
|
+
| `CacheError` | Abstract base — don't throw directly, match against it to catch any cache error | `catch (e) { if (e instanceof CacheError) … }` |
|
|
477
|
+
| `CacheConfigurationError` | Bad TTL string, `ttl` + `expiresAt` together, `expiresAt` in the past, missing required driver option (e.g. redis without url/host, file without directory), attempting to use an unregistered driver name | Fix the config / call site. Never catch at runtime — this is a programmer error, not a user-facing one. |
|
|
478
|
+
| `CacheConnectionError` | Declared for driver connection failures. Not thrown by any built-in driver today (Redis currently logs the error and emits an `"error"` event instead of throwing on failed connect). | Reserved for future use. |
|
|
479
|
+
| `CacheDriverNotInitializedError` | Any data op called before `cache.init()` / `cache.use()` | Call `cache.init()` at app startup. Tests often forget this — add a `beforeEach`. |
|
|
480
|
+
| `CacheUnsupportedError` | Driver doesn't implement the requested op. Today: `update` / `merge` on the file driver; `set({ vector })` and `similar()` on file / redis / pg-without-`vector`-config. | Switch driver (memory family for dev similarity, `pg` with `vector` config for production), or queue the op. |
|
|
481
|
+
| `CacheConcurrencyError` | Declared for future optimistic-concurrency exhaustion on Redis `update()` | Not thrown today. Reserved for the v2.1 `WATCH`/`MULTI` implementation. |
|
|
482
|
+
|
|
483
|
+
## Special case — `setNX` unsupported
|
|
484
|
+
|
|
485
|
+
Calling `cache.setNX(...)` on a driver that doesn't implement it throws a plain `Error`, not a `CacheUnsupportedError`:
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
// Error: "setNX is not supported by the current cache driver: memory"
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
This is legacy. The v2-preferred way is `cache.set(k, v, { onConflict: "create" })` which works on every driver (Redis native, others emulated). See [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md).
|
|
492
|
+
|
|
493
|
+
## Patterns
|
|
494
|
+
|
|
495
|
+
### Catch-all at the boundary
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
try {
|
|
499
|
+
await doCachedWork();
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (error instanceof CacheError) {
|
|
502
|
+
logger.warn("cache unavailable, degrading", error);
|
|
503
|
+
await doWorkWithoutCache();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Selective — configuration vs runtime
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
try {
|
|
514
|
+
await cache.set("k", v, userSuppliedOptions);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (error instanceof CacheConfigurationError) {
|
|
517
|
+
return res.status(400).json({ error: "invalid TTL options" });
|
|
518
|
+
}
|
|
519
|
+
throw error;
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Driver-missing fallback
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
try {
|
|
527
|
+
await cache.merge("user:1", { lastSeen: Date.now() });
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (error instanceof CacheUnsupportedError) {
|
|
530
|
+
// File driver in dev — degrade gracefully
|
|
531
|
+
const current = await cache.get("user:1");
|
|
532
|
+
await cache.set("user:1", { ...current, lastSeen: Date.now() });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## Things the driver does NOT throw
|
|
540
|
+
|
|
541
|
+
- **Missing keys** — `get()` returns `null`, never throws. Tests checking "key not in cache" should assert `resolves.toBeNull()`, not `rejects.toThrow`.
|
|
542
|
+
- **Expired entries** — `get()` returns `null` and emits `"miss"` + `"expired"` events. No throw.
|
|
543
|
+
- **Flush on empty** — `flush()` succeeds silently when there's nothing to flush.
|
|
544
|
+
- **Concurrent writes clobbering each other** — last-write-wins by default. Use `update()` or `onConflict: "create"` if you need protection.
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
## observe-cache `@warlock.js/cache/observe-cache/SKILL.md`
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
name: observe-cache
|
|
551
|
+
description: 'Cache observability — cache.metrics() for aggregate hit rate / latency p50/p95/p99 + event bus (cache.on(''hit'' / ''miss'' / ''set'' / ''removed'' / ''flushed'' / ''expired'' / ''error'', ...)). Triggers: `cache.metrics`, `cache.resetMetrics`, `cache.on`, `hit`, `miss`, `removed`, `flushed`, `error`, `hitRate`, `latencyMs`; "show cache hit rate", "page on cache errors", "is my cache being hit", "export metrics to prometheus"; typical import `import { cache } from "@warlock.js/cache"`. Skip: error classes — `@warlock.js/cache/handle-cache-errors/SKILL.md`; competing libs `prom-client`, `statsd-client`.'
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
# Cache observability — `cache.metrics()` and the event bus
|
|
555
|
+
|
|
556
|
+
Two layers, different jobs.
|
|
557
|
+
|
|
558
|
+
## Layer 1 — `cache.metrics()` for aggregate health
|
|
559
|
+
|
|
560
|
+
Built-in collector subscribed to the manager's event bus. Returns a snapshot whenever you ask:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
const m = cache.metrics();
|
|
564
|
+
// {
|
|
565
|
+
// hits, misses, sets, removed, errors,
|
|
566
|
+
// hitRate,
|
|
567
|
+
// latencyMs: { p50, p95, p99, samples },
|
|
568
|
+
// byDriver: { memory: {...}, redis: {...} },
|
|
569
|
+
// startedAt,
|
|
570
|
+
// }
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**Lazy** — the collector attaches on the first `cache.metrics()` / `cache.resetMetrics()` call. Apps that never read metrics pay zero cost. Earlier events are not retroactively counted, so if you want metrics on every op including the first, call `cache.metrics()` once during startup right after `cache.init()`.
|
|
574
|
+
|
|
575
|
+
**Survives `cache.use()` switches** — listens at the manager level, re-attaches to every loaded driver.
|
|
576
|
+
|
|
577
|
+
**Latency** is sampled by the manager around `get` / `set` / `remove` into a circular buffer (default 1000 samples per driver). Percentiles are computed at snapshot time. Older samples age out, so percentiles reflect the recent ~1000 ops.
|
|
578
|
+
|
|
579
|
+
`cache.resetMetrics()` zeroes counters + drops the buffer + bumps `startedAt`.
|
|
580
|
+
|
|
581
|
+
## Layer 2 — Raw events for per-event reactions
|
|
582
|
+
|
|
583
|
+
When you need to react to specific events (alerting, audit logs, debugging), subscribe to the event bus:
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
cache.on("error", ({ key, error }) => {
|
|
587
|
+
pagerDuty.trigger(`Cache error on ${key}`, error);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
cache.on("miss", ({ key, driver }) => {
|
|
591
|
+
if (key.startsWith("hot.")) auditLog.miss(key, driver);
|
|
592
|
+
});
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Available events: `hit`, `miss`, `set`, `removed`, `flushed`, `expired`, `connected`, `disconnected`, `error`.
|
|
596
|
+
|
|
597
|
+
Listeners attached via `cache.on(...)` survive driver switches the same way the metrics collector does.
|
|
598
|
+
|
|
599
|
+
## Which one to reach for
|
|
600
|
+
|
|
601
|
+
| Goal | Use |
|
|
602
|
+
|---|---|
|
|
603
|
+
| Show hit rate / latency in a dashboard | `cache.metrics()` |
|
|
604
|
+
| Page on cache errors | `cache.on("error", ...)` |
|
|
605
|
+
| Periodic export to Prometheus / StatsD | `cache.metrics()` + `setInterval` + `resetMetrics()` |
|
|
606
|
+
| Audit log of every removal | `cache.on("removed", ...)` |
|
|
607
|
+
| Detect a specific anti-pattern (e.g. always-miss key) | `cache.on("miss", ...)` |
|
|
608
|
+
| Debug "is the cache being hit at all?" in dev | `cache.metrics()` once at the end of a flow |
|
|
609
|
+
|
|
610
|
+
Both layers can coexist — events fire whether the metrics collector is attached or not.
|
|
611
|
+
|
|
612
|
+
## Common shapes
|
|
613
|
+
|
|
614
|
+
### Periodic export, then reset
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
setInterval(() => {
|
|
618
|
+
const snapshot = cache.metrics();
|
|
619
|
+
exporter.send(snapshot);
|
|
620
|
+
cache.resetMetrics();
|
|
621
|
+
}, 60_000);
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
The snapshot now reflects the last minute of traffic, not the lifetime.
|
|
625
|
+
|
|
626
|
+
### Boundary measurement
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
cache.resetMetrics();
|
|
630
|
+
await runTrafficBurst();
|
|
631
|
+
console.log(cache.metrics());
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
Useful for benchmarks, soak tests, "did the cache help?" before/after comparisons.
|
|
635
|
+
|
|
636
|
+
### Per-driver isolation
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
const m = cache.metrics();
|
|
640
|
+
console.log(`memory hit rate: ${m.byDriver.memory?.hitRate ?? 0}`);
|
|
641
|
+
console.log(`redis p95: ${m.byDriver.redis?.latencyMs.p95 ?? 0}ms`);
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Drivers that never fire events stay absent from `byDriver` — guard with `?.` and `?? 0`.
|
|
645
|
+
|
|
646
|
+
## Things NOT to do
|
|
647
|
+
|
|
648
|
+
- Don't subscribe to events to count things and ignore the built-in collector — that's exactly what it's built to do.
|
|
649
|
+
- Don't call `cache.metrics()` on every request expecting per-request data — it returns a running aggregate. Use the event bus for per-call observability.
|
|
650
|
+
- Don't expect lifetime percentiles. The buffer is bounded — for a 24h p95, sample-and-aggregate at your exporter, don't ask cache to remember every op forever.
|
|
651
|
+
- Don't forget to attach early. If startup metrics matter, call `cache.metrics()` right after `cache.init()`.
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
## overview `@warlock.js/cache/overview/SKILL.md`
|
|
655
|
+
|
|
656
|
+
---
|
|
657
|
+
name: overview
|
|
658
|
+
description: 'Front-door orientation for `@warlock.js/cache` — multi-driver caching (memory / memoryExtended / lru / file / redis / pg / null / mock) with a single `cache` API: get/set/has/pull/remember, TTL shapes, tag-based invalidation, key namespaces, distributed locks, stale-while-revalidate, atomic update/merge, cache lists, vector similarity, metrics + events, and the `cached()` HOF. TRIGGER when: code imports from `@warlock.js/cache` (`cache`, `cached`, `setCacheConfigurations`, a `*CacheDriver`); user asks "what does @warlock.js/cache do", "which cache driver", "cache TTL / tags / invalidation", "distributed lock", "stale-while-revalidate", "semantic / vector cache", "compare with node-cache / keyv / cache-manager"; package.json adds `@warlock.js/cache`. Skip: specific task already known — load the matching task skill directly (`cache-basics`, `pick-cache-driver`, `configure-set-options`, `use-cache-tags`, `use-cache-namespace`, `use-cache-list`, `use-cache-lock`, `use-swr`, `use-cached-hof`, `use-cache-similarity`, `use-cache-update-merge`, `use-cache-atomic`, `use-cache-bulk`, `use-cache-utils`, `apply-cache-patterns`, `observe-cache`, `handle-cache-errors`, `configure-pg-cache`, `test-cache-code`).'
|
|
659
|
+
---
|
|
660
|
+
|
|
661
|
+
# `@warlock.js/cache` — overview
|
|
662
|
+
|
|
663
|
+
One cache API over many drivers. Pick a driver (memory, memoryExtended, LRU, file, Redis, Postgres, null, mock), wire it once, and call `cache.get` / `cache.set` / `cache.remember` everywhere. On top of the key-value basics it adds tag invalidation, key namespaces, distributed locks, stale-while-revalidate, atomic update/merge, ordered lists, vector similarity, and built-in metrics + events.
|
|
664
|
+
|
|
665
|
+
## When to reach for it
|
|
666
|
+
|
|
667
|
+
- You need a cache abstraction that swaps drivers per environment (memory in dev, Redis in prod) without changing call sites.
|
|
668
|
+
- You want more than get/set — tag-based bulk invalidation, locks for stampede safety, SWR for slow upstreams, or a semantic cache over vectors.
|
|
669
|
+
- You're inside a Warlock app (the framework wires the driver from config) — or standalone, calling `setCacheConfigurations` + `cache.init` yourself.
|
|
670
|
+
|
|
671
|
+
Skip if a plain `Map` covers your needs and you'll never need a second driver, TTLs, or invalidation.
|
|
672
|
+
|
|
673
|
+
## The mental model in one paragraph
|
|
674
|
+
|
|
675
|
+
A single `cache` singleton fronts a configured driver. `cache.set(key, value, options?)` writes (TTL via `ttl`/`expiresAt`, inline `tags`, `onConflict`, a per-call `driver` override, or a `vector` for similarity); `cache.get` / `has` / `pull` / `remove` / `many` / `remember` read. Tags let you invalidate sets of keys you can't enumerate ahead of time; namespaces auto-prefix keys with shared TTL/tag defaults; locks serialize work across processes; SWR serves stale-but-instant while refreshing in the background; `update`/`merge` do atomic read-modify-write; `list<T>(key)` gives ordered collections; `similar(vector, …)` does nearest-neighbor retrieval. `cache.metrics()` and `cache.on(event, …)` make it observable.
|
|
676
|
+
|
|
677
|
+
## Skills index
|
|
678
|
+
|
|
679
|
+
Nineteen task skills. Most apps start with `cache-basics` + `pick-cache-driver` + `configure-set-options`.
|
|
680
|
+
|
|
681
|
+
### Foundations
|
|
682
|
+
|
|
683
|
+
- [`cache-basics`](@warlock.js/cache/cache-basics/SKILL.md) — the `cache` singleton, primary ops (`set`/`get`/`has`/`pull`/`remove`/`many`/`forever`/`increment`/`decrement`/`remember`), TTL shapes, init flow. **Start here.**
|
|
684
|
+
- [`pick-cache-driver`](@warlock.js/cache/pick-cache-driver/SKILL.md) — choose + configure a driver: `null` / `memory` / `memoryExtended` / `lru` / `file` / `redis` / `pg` / `mock`; `globalPrefix` for multi-tenant scoping.
|
|
685
|
+
- [`configure-set-options`](@warlock.js/cache/configure-set-options/SKILL.md) — `cache.set`'s third argument: `ttl`, `expiresAt`, `tags`, `onConflict` (create/update/upsert), `driver`, `vector`.
|
|
686
|
+
- [`configure-pg-cache`](@warlock.js/cache/configure-pg-cache/SKILL.md) — Postgres driver: KV-only (default) or pgvector mode; caller owns the `pg.Pool`, `driver.schema()` emits the DDL.
|
|
687
|
+
|
|
688
|
+
### Invalidation + scoping
|
|
689
|
+
|
|
690
|
+
- [`use-cache-tags`](@warlock.js/cache/use-cache-tags/SKILL.md) — tag on write, `cache.tags([...]).invalidate()` drops every bound key.
|
|
691
|
+
- [`use-cache-namespace`](@warlock.js/cache/use-cache-namespace/SKILL.md) — `cache.namespace(prefix, options?)` auto-prefixes keys with scope-level TTL/tag defaults and nested scopes.
|
|
692
|
+
|
|
693
|
+
### Patterns
|
|
694
|
+
|
|
695
|
+
- [`use-cached-hof`](@warlock.js/cache/use-cached-hof/SKILL.md) — `cached(fn, options)` wraps an async function; one declaration, many call sites, a bound `.invalidate(...args)`.
|
|
696
|
+
- [`apply-cache-patterns`](@warlock.js/cache/apply-cache-patterns/SKILL.md) — `remember()` memoization, distributed locks via `onConflict: "create"`, negative caching, counters, per-tenant prefix, `CACHE_FOR.*` TTL constants.
|
|
697
|
+
- [`use-cache-lock`](@warlock.js/cache/use-cache-lock/SKILL.md) — `cache.lock(key, ttl, fn)`: acquire → run → auto-release. For cron/imports/migrations and idempotent webhook/payment processing.
|
|
698
|
+
- [`use-swr`](@warlock.js/cache/use-swr/SKILL.md) — `cache.swr(key, { freshTtl, staleTtl }, fn)`: instant when fresh, instant + background refresh when stale, blocks only when fully expired.
|
|
699
|
+
- [`use-cache-update-merge`](@warlock.js/cache/use-cache-update-merge/SKILL.md) — atomic read-modify-write via `cache.update(key, fn)` / `cache.merge(key, partial)`, serialized per key, TTL-preserving.
|
|
700
|
+
- [`use-cache-atomic`](@warlock.js/cache/use-cache-atomic/SKILL.md) — `cache.increment` / `cache.decrement` counters; per-driver atomicity + TTL behavior.
|
|
701
|
+
- [`use-cache-bulk`](@warlock.js/cache/use-cache-bulk/SKILL.md) — `cache.many(keys)` / `cache.setMany(record, ttl?)` for batch reads/writes.
|
|
702
|
+
- [`use-cache-list`](@warlock.js/cache/use-cache-list/SKILL.md) — `cache.list<T>(key)`: `push`/`unshift`/`pop`/`shift`/`slice`/`trim`/`clear` for queues, recent-N buffers, sliding windows.
|
|
703
|
+
- [`use-cache-similarity`](@warlock.js/cache/use-cache-similarity/SKILL.md) — `cache.similar(vector, { topK, threshold?, tags? })` for semantic caches, RAG retrieval, nearest-neighbor lookup.
|
|
704
|
+
|
|
705
|
+
### Operations
|
|
706
|
+
|
|
707
|
+
- [`observe-cache`](@warlock.js/cache/observe-cache/SKILL.md) — `cache.metrics()` (hit rate, latency p50/p95/p99) + the event bus (`cache.on("hit" | "miss" | "set" | "removed" | "flushed" | "expired" | "connected" | "disconnected" | "error", …)`).
|
|
708
|
+
- [`handle-cache-errors`](@warlock.js/cache/handle-cache-errors/SKILL.md) — the error classes: `CacheError`, `CacheConfigurationError`, `CacheConnectionError`, `CacheDriverNotInitializedError`, `CacheUnsupportedError`, `CacheConcurrencyError`.
|
|
709
|
+
- [`test-cache-code`](@warlock.js/cache/test-cache-code/SKILL.md) — `MockCacheDriver` (behavioral assertions), `MemoryCacheDriver` (full-stack), `NullCacheDriver` (graceful degradation).
|
|
710
|
+
|
|
711
|
+
### Utilities
|
|
712
|
+
|
|
713
|
+
- [`use-cache-utils`](@warlock.js/cache/use-cache-utils/SKILL.md) — low-level re-exports: `parseTtl`, `parseCacheKey`, `resolveTtl`, `expiresAtToTtl`, `mergeTagSets`, `injectTags`, `cosineSimilarity`, and the `CACHE_FOR` TTL enum.
|
|
714
|
+
|
|
715
|
+
## What this package deliberately doesn't do
|
|
716
|
+
|
|
717
|
+
- **Be a database.** It's a cache — entries expire, drivers may evict. Don't store anything you can't recompute.
|
|
718
|
+
- **Guarantee cross-driver feature parity.** Vector similarity needs a memory-family driver (`memory` / `memoryExtended` / `lru`, brute force) or `pg` with pgvector; `redis` and `file` raise `CacheUnsupportedError`. Locks/tags behave per driver. Unsupported ops raise `CacheUnsupportedError` rather than silently degrading.
|
|
719
|
+
- **Own your Postgres pool.** The `pg` driver takes the `pg.Pool`/`Client` you already built and never closes it — connection lifecycle stays yours. (Redis is the opposite: you pass `url`/`host` options and the driver builds and owns the client, calling `quit()` on `disconnect()`.)
|
|
720
|
+
|
|
721
|
+
## See also
|
|
722
|
+
|
|
723
|
+
- [`@warlock.js/core/overview/SKILL.md`](@warlock.js/core/overview/SKILL.md) — wires the cache driver from app config and exposes the singleton.
|
|
724
|
+
- `mongez-agent-kit-authoring-skills` (load via agent-kit sync) — how this becomes `.claude/skills/warlock-js-cache-overview/`.
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
## pick-cache-driver `@warlock.js/cache/pick-cache-driver/SKILL.md`
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
name: pick-cache-driver
|
|
731
|
+
description: 'Pick a cache driver — null / memory / memoryExtended / lru / file / redis / pg / mock — and configure it. Triggers: `cache.setCacheConfigurations`, `BaseCacheDriver`, `cache.use`, `cache.load`, `cache.driver`, `globalPrefix`; "which cache driver should I use", "configure redis driver", "register custom cache driver", "multi-tenant scoping"; typical import `import { cache, BaseCacheDriver } from "@warlock.js/cache"`. Skip: cache CRUD — `@warlock.js/cache/cache-basics/SKILL.md`; pg setup — `@warlock.js/cache/configure-pg-cache/SKILL.md`; competing libs `lru-cache`, `node-cache`, `keyv`, `ioredis`; native `Map`.'
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
# Cache drivers — pick the right one
|
|
735
|
+
|
|
736
|
+
Seven production drivers + a mock driver ship in-box. Pick by durability, scope, and workload.
|
|
737
|
+
|
|
738
|
+
| Driver | Process scope | Persists on restart | Good for | Avoid when |
|
|
739
|
+
| --- | --- | --- | --- | --- |
|
|
740
|
+
| `null` | — | — | Disabling cache in tests; feature-flagging off | You actually want caching |
|
|
741
|
+
| `memory` | Single process | No | Hot in-process data with default TTL; smallest latency | Multi-process / multi-node |
|
|
742
|
+
| `memoryExtended` | Single process | No | Sliding-window TTL (TTL resets on every read) | Any multi-process deploy |
|
|
743
|
+
| `lru` | Single process | No | Bounded in-memory caches (capacity-based eviction) | Need cross-process sharing |
|
|
744
|
+
| `file` | Single host | Yes | Build artefacts, local dev persistence across restarts | Concurrency (no locks); multi-host |
|
|
745
|
+
| `redis` | Shared | Yes (Redis-managed) | Anything shared across processes / nodes | Single-process-only workload — overkill |
|
|
746
|
+
| `pg` | Shared | Yes (Postgres-managed) | You already run Postgres; semantic caching / RAG via pgvector | High-throughput hot reads (Redis is faster) |
|
|
747
|
+
|
|
748
|
+
## Capability matrix
|
|
749
|
+
|
|
750
|
+
| Capability | null | memory | memoryExt | lru | file | redis | pg |
|
|
751
|
+
| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
|
752
|
+
| `set` / `get` / `remove` / `flush` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
753
|
+
| TTL (number or string) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
754
|
+
| Sliding TTL on read | — | — | ✓ | — | — | — | — |
|
|
755
|
+
| `removeNamespace` | noop | ✓ | ✓ | ✓ (prefix-scan) | ✓ | ✓ | ✓ (LIKE prefix) |
|
|
756
|
+
| `onConflict: "create"` / `"update"` | noop | emulated | emulated | emulated | emulated | native `NX`/`XX` | native (INSERT ON CONFLICT) |
|
|
757
|
+
| Native increment / decrement | — | ✓ | ✓ | ✓ | ✓ | atomic `INCRBY`/`DECRBY` | ✓ |
|
|
758
|
+
| `update()` / `merge()` | ✓ | ✓ | ✓ | ✓ | ✗ throws | ✓ (single-process safety only today) | ✓ |
|
|
759
|
+
| List sub-API | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (O(n) JSON blob today; native LPUSH/LRANGE in v2.1) | ✓ |
|
|
760
|
+
| Tagged invalidation | noop | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (native GIN(tags)) |
|
|
761
|
+
| `similar()` / `set({ vector })` | returns `[]` / noop | ✓ brute force | ✓ brute force | ✓ brute force | ✗ throws | ✗ throws (Phase 2 backlog) | ✓ (with `vector` config — pgvector) |
|
|
762
|
+
|
|
763
|
+
## Global config TTL — accepts number or string
|
|
764
|
+
|
|
765
|
+
```ts
|
|
766
|
+
options: {
|
|
767
|
+
redis: { url: "...", ttl: "7d" }, // string OK
|
|
768
|
+
memory: { ttl: 3600 }, // number OK
|
|
769
|
+
lru: { capacity: 10_000 }, // LRU has no TTL option today
|
|
770
|
+
file: { directory: () => "/var/cache/myapp", ttl: "1h" },
|
|
771
|
+
pg: { client: pool, ttl: "1h" }, // KV-only
|
|
772
|
+
// pg with pgvector:
|
|
773
|
+
// pg: { client: pool, vector: { dimensions: 1536, index: "hnsw" } },
|
|
774
|
+
}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
## Global prefix (multi-tenant scoping)
|
|
778
|
+
|
|
779
|
+
Every driver accepts `globalPrefix: string | (() => string)`. The function form runs per call — pair it with request-local async context to scope every cached key to the current tenant / user / client automatically:
|
|
780
|
+
|
|
781
|
+
```ts
|
|
782
|
+
options: {
|
|
783
|
+
redis: {
|
|
784
|
+
url: "...",
|
|
785
|
+
globalPrefix: () => `tenant-${currentContext.tenantId}`,
|
|
786
|
+
},
|
|
787
|
+
}
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
## Registering a custom driver
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
import { BaseCacheDriver, cache } from "@warlock.js/cache";
|
|
794
|
+
|
|
795
|
+
class MemcachedCacheDriver extends BaseCacheDriver<MyClient, MyOptions> {
|
|
796
|
+
public name = "memcached";
|
|
797
|
+
// … implement set / get / remove / flush / removeNamespace / connect
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
cache.setCacheConfigurations({
|
|
801
|
+
default: "memcached",
|
|
802
|
+
drivers: { memcached: MemcachedCacheDriver },
|
|
803
|
+
options: { memcached: { host: "localhost" } },
|
|
804
|
+
});
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
Extending `BaseCacheDriver` gives you free: TTL parsing, key parsing, event emission, stampede-safe `remember`, deep-clone-on-read, default `update` / `merge` / `list` implementations.
|
|
808
|
+
|
|
809
|
+
## Runtime driver options — `cache.use(name, options)`
|
|
810
|
+
|
|
811
|
+
Some driver options can only be built at runtime (`pg`'s `client: pg.Pool`, pre-wired clients). Pass them as the second arg to `cache.use` / `cache.load` / `cache.driver` — they merge over `setCacheConfigurations({ options })` per-key, runtime wins.
|
|
812
|
+
|
|
813
|
+
```ts
|
|
814
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
815
|
+
|
|
816
|
+
cache.setCacheConfigurations({
|
|
817
|
+
default: "pg",
|
|
818
|
+
drivers: { pg: PgCacheDriver },
|
|
819
|
+
options: { pg: { table: "cache" } }, // static
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
await cache.use("pg", { client: pool }); // runtime — skip init() in this case
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
Constraints:
|
|
826
|
+
- The driver name must be registered in `setCacheConfigurations({ drivers })` — runtime options don't bypass registration.
|
|
827
|
+
- Once a driver is loaded, calling `use`/`load`/`driver` again with **non-empty** new options throws `CacheConfigurationError`. Register a second driver name if you need a different config.
|
|
828
|
+
- Calling without options (or with `{}`) on an already-loaded driver returns the cached instance silently.
|
|
829
|
+
|
|
830
|
+
## Per-call driver override
|
|
831
|
+
|
|
832
|
+
When most writes go to the default driver but one call needs a different one:
|
|
833
|
+
|
|
834
|
+
```ts
|
|
835
|
+
await cache.set("audit:event", event, { driver: "redis" });
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
The manager loads (and connects) the override driver lazily on first use, then routes that single operation through it without mutating `currentDriver`.
|
|
839
|
+
|
|
840
|
+
## See also
|
|
841
|
+
|
|
842
|
+
- [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md) — full pg setup (KV-only and pgvector mode)
|
|
843
|
+
- [`@warlock.js/cache/test-cache-code/SKILL.md`](@warlock.js/cache/test-cache-code/SKILL.md) — `MockCacheDriver` and `NullCacheDriver` for tests
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
## test-cache-code `@warlock.js/cache/test-cache-code/SKILL.md`
|
|
847
|
+
|
|
848
|
+
---
|
|
849
|
+
name: test-cache-code
|
|
850
|
+
description: 'Test code that touches cache — MockCacheDriver (behavioral assertions with wasCalled / callLog), MemoryCacheDriver (full-stack), NullCacheDriver (graceful-degradation). Triggers: `MockCacheDriver`, `MemoryCacheDriver`, `NullCacheDriver`, `wasCalled`, `callLog`, `getStored`, `reset`, `cache.on`; "assert cache was invalidated", "test code that uses cache.set", "mock cache in vitest", "test similarity without a real embedder", "stub the pg cache driver"; typical import `import { cache, MockCacheDriver, MemoryCacheDriver } from "@warlock.js/cache"`. Skip: real driver picks — `@warlock.js/cache/pick-cache-driver/SKILL.md`; competing libs `jest-mock`, `sinon`, `redis-mock`; native `vi.fn`.'
|
|
851
|
+
---
|
|
852
|
+
|
|
853
|
+
# Testing code that touches cache
|
|
854
|
+
|
|
855
|
+
Three good strategies — pick based on what you're testing.
|
|
856
|
+
|
|
857
|
+
## Strategy 1 — `MockCacheDriver` for behavioral assertions (preferred)
|
|
858
|
+
|
|
859
|
+
When you want to assert "did my service actually invalidate the cache after the update?" or "was `set` called with the right TTL?", reach for `MockCacheDriver`. It implements the full driver contract on a `Map` and adds three introspection helpers:
|
|
860
|
+
|
|
861
|
+
- `wasCalled(operation, key?)` — was a given op invoked? Optional key matched post-`parseKey`.
|
|
862
|
+
- `getStored(key)` — raw stored value, bypassing TTL handling and clone protection.
|
|
863
|
+
- `reset()` — wipe storage, tag index, and call log in one call.
|
|
864
|
+
- `callLog: CacheCall[]` — ordered record of every op (operation, parsed key, raw args, timestamp).
|
|
865
|
+
|
|
866
|
+
```ts
|
|
867
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
868
|
+
import { cache, MockCacheDriver } from "@warlock.js/cache";
|
|
869
|
+
|
|
870
|
+
describe("UserService.update", () => {
|
|
871
|
+
let driver: MockCacheDriver;
|
|
872
|
+
|
|
873
|
+
beforeEach(async () => {
|
|
874
|
+
cache.setCacheConfigurations({
|
|
875
|
+
default: "mock",
|
|
876
|
+
logging: false,
|
|
877
|
+
drivers: { mock: MockCacheDriver },
|
|
878
|
+
options: { mock: {} },
|
|
879
|
+
});
|
|
880
|
+
await cache.init();
|
|
881
|
+
|
|
882
|
+
driver = cache.currentDriver as MockCacheDriver;
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
afterEach(async () => {
|
|
886
|
+
driver.reset();
|
|
887
|
+
await cache.disconnect();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("invalidates the user cache after update", async () => {
|
|
891
|
+
await new UserService().update(42, { name: "Jane" });
|
|
892
|
+
|
|
893
|
+
expect(driver.wasCalled("remove", "users.42")).toBe(true);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it("caches with the right TTL on read-through", async () => {
|
|
897
|
+
await new UserService().getProfile(1);
|
|
898
|
+
|
|
899
|
+
const setCall = driver.callLog.find((call) => call.operation === "set");
|
|
900
|
+
expect(setCall?.args[1]).toBe("1h");
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
`wasCalled` normalizes object keys, so `wasCalled("set", { id: 1 })` and `wasCalled("set", "id.1")` match the same call.
|
|
906
|
+
|
|
907
|
+
## Strategy 2 — `MemoryCacheDriver` for full-stack integration tests
|
|
908
|
+
|
|
909
|
+
When you want the real read/write semantics (eviction, TTL expiry, similarity scoring) without the introspection ceremony, `MemoryCacheDriver` is the right pick. Same setup pattern as the mock — swap `MockCacheDriver` for `MemoryCacheDriver`.
|
|
910
|
+
|
|
911
|
+
`MockCacheDriver` does NOT implement `similar()` — vector writes are recorded into the call log but nearest-neighbor scoring is not available. Tests that call `cache.similar(...)` should use the memory driver.
|
|
912
|
+
|
|
913
|
+
## Strategy 3 — `NullCacheDriver` when you need cache *off*
|
|
914
|
+
|
|
915
|
+
Use `NullCacheDriver` to disable caching entirely for code paths that should still work without a cache (graceful-degradation tests):
|
|
916
|
+
|
|
917
|
+
```ts
|
|
918
|
+
cache.setCacheConfigurations({
|
|
919
|
+
default: "null",
|
|
920
|
+
drivers: { null: NullCacheDriver },
|
|
921
|
+
options: { null: {} },
|
|
922
|
+
});
|
|
923
|
+
await cache.init();
|
|
924
|
+
|
|
925
|
+
// All cache ops no-op; get() always returns null; set() silently discards.
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
## Mocking Redis (for driver-level tests, not app code)
|
|
929
|
+
|
|
930
|
+
For tests that specifically exercise `RedisCacheDriver`, use `vi.mock("redis")` with an in-memory fake. Example (condensed from `redis-cache-driver.spec.ts`):
|
|
931
|
+
|
|
932
|
+
```ts
|
|
933
|
+
import { vi } from "vitest";
|
|
934
|
+
|
|
935
|
+
class FakeRedisClient {
|
|
936
|
+
public store = new Map<string, string>();
|
|
937
|
+
private expires = new Map<string, number>();
|
|
938
|
+
public on() { return this; }
|
|
939
|
+
public async connect() {}
|
|
940
|
+
public async quit() {}
|
|
941
|
+
public async set(key: string, value: string, opts?: { EX?: number; NX?: boolean; XX?: boolean }) {
|
|
942
|
+
if (opts?.NX && this.store.has(key)) return null;
|
|
943
|
+
if (opts?.XX && !this.store.has(key)) return null;
|
|
944
|
+
this.store.set(key, value);
|
|
945
|
+
if (opts?.EX) this.expires.set(key, Date.now() + opts.EX * 1000);
|
|
946
|
+
return "OK";
|
|
947
|
+
}
|
|
948
|
+
public async get(key: string) {
|
|
949
|
+
const ttl = this.expires.get(key);
|
|
950
|
+
if (ttl && ttl < Date.now()) {
|
|
951
|
+
this.store.delete(key);
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
return this.store.get(key) ?? null;
|
|
955
|
+
}
|
|
956
|
+
public async del(keys: string | string[]) {
|
|
957
|
+
const arr = Array.isArray(keys) ? keys : [keys];
|
|
958
|
+
let count = 0;
|
|
959
|
+
for (const k of arr) if (this.store.delete(k)) count++;
|
|
960
|
+
return count;
|
|
961
|
+
}
|
|
962
|
+
public async keys(pattern: string) {
|
|
963
|
+
const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
|
|
964
|
+
return [...this.store.keys()].filter((k) => regex.test(k));
|
|
965
|
+
}
|
|
966
|
+
public async flushAll() { this.store.clear(); this.expires.clear(); }
|
|
967
|
+
public async incrBy(key: string, n: number) {
|
|
968
|
+
const next = Number(this.store.get(key) ?? 0) + n;
|
|
969
|
+
this.store.set(key, String(next));
|
|
970
|
+
return next;
|
|
971
|
+
}
|
|
972
|
+
public async decrBy(key: string, n: number) { return this.incrBy(key, -n); }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const fakeClient = new FakeRedisClient();
|
|
976
|
+
vi.mock("redis", () => ({ createClient: vi.fn(() => fakeClient) }));
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
**One-time gotcha:** `RedisCacheDriver` kicks off an async `loadRedis()` at module import that resolves its internal "redis is available" flag. Before running any test that calls `connect()`, wait a short tick after the first dynamic import so the flag flips. The in-tree spec uses:
|
|
980
|
+
|
|
981
|
+
```ts
|
|
982
|
+
async function importDriver() {
|
|
983
|
+
const mod = await import("./redis-cache-driver");
|
|
984
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
985
|
+
return mod.RedisCacheDriver;
|
|
986
|
+
}
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
## Silencing cache logs in tests
|
|
990
|
+
|
|
991
|
+
The in-package vitest config runs `silent: true`, but if you're outside the package, explicitly disable logging:
|
|
992
|
+
|
|
993
|
+
```ts
|
|
994
|
+
cache.setCacheConfigurations({
|
|
995
|
+
default: "memory",
|
|
996
|
+
logging: false, // <-- here
|
|
997
|
+
drivers: { memory: MemoryCacheDriver },
|
|
998
|
+
options: { memory: {} },
|
|
999
|
+
});
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
Or per-driver: `driver.setLoggingState(false)`.
|
|
1003
|
+
|
|
1004
|
+
## Spying on events
|
|
1005
|
+
|
|
1006
|
+
Every driver emits `hit`, `miss`, `set`, `removed`, `flushed`, `expired`. Attach listeners to assert cache behavior without inspecting internal state:
|
|
1007
|
+
|
|
1008
|
+
```ts
|
|
1009
|
+
const hits = vi.fn();
|
|
1010
|
+
cache.on("hit", hits);
|
|
1011
|
+
|
|
1012
|
+
await service.getProfile("1");
|
|
1013
|
+
await service.getProfile("1");
|
|
1014
|
+
|
|
1015
|
+
expect(hits).toHaveBeenCalledTimes(1); // second call was a hit
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
Listeners registered via `cache.on(...)` automatically attach to any driver loaded later, so order of `on()` vs `init()` doesn't matter.
|
|
1019
|
+
|
|
1020
|
+
## Tests for `update` / `merge` concurrency
|
|
1021
|
+
|
|
1022
|
+
The chain-serialization guarantee is worth testing when your code fans out concurrent updates:
|
|
1023
|
+
|
|
1024
|
+
```ts
|
|
1025
|
+
await cache.set("counter", 0);
|
|
1026
|
+
|
|
1027
|
+
await Promise.all(
|
|
1028
|
+
Array.from({ length: 10 }, () =>
|
|
1029
|
+
cache.update<number>("counter", (c) => (c ?? 0) + 1),
|
|
1030
|
+
),
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
await expect(cache.get("counter")).resolves.toBe(10);
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
Runs in-process only — for cross-process safety see [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md).
|
|
1037
|
+
|
|
1038
|
+
## Testing similarity code paths
|
|
1039
|
+
|
|
1040
|
+
`MemoryCacheDriver` runs `similar()` brute-force in-process — perfect for tests of code that calls `cache.similar(...)`. Use stable, hand-written vectors (don't call a real embedder in tests):
|
|
1041
|
+
|
|
1042
|
+
```ts
|
|
1043
|
+
beforeEach(async () => {
|
|
1044
|
+
cache.setCacheConfigurations({
|
|
1045
|
+
default: "memory",
|
|
1046
|
+
logging: false,
|
|
1047
|
+
drivers: { memory: MemoryCacheDriver },
|
|
1048
|
+
options: { memory: {} },
|
|
1049
|
+
});
|
|
1050
|
+
await cache.init();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("returns the most similar doc above threshold", async () => {
|
|
1054
|
+
await cache.set("a", { text: "alpha" }, { vector: [1, 0, 0] });
|
|
1055
|
+
await cache.set("b", { text: "beta" }, { vector: [0, 1, 0] });
|
|
1056
|
+
|
|
1057
|
+
const hits = await cache.similar([1, 0, 0], { topK: 1, threshold: 0.5 });
|
|
1058
|
+
|
|
1059
|
+
expect(hits).toHaveLength(1);
|
|
1060
|
+
expect(hits[0].key).toBe("a");
|
|
1061
|
+
});
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
## Testing the `pg` driver without a real Postgres
|
|
1065
|
+
|
|
1066
|
+
The `pg` driver accepts any object satisfying `PgClientLike` (`{ query(text, values) }`). For unit tests, hand-roll a minimal Map-backed fake — see `src/drivers/pg-cache-driver.spec.ts` for a 60-line `FakePool` that recognizes the exact SQL shapes the driver issues. For integration coverage, gate a real-PG suite on a `POSTGRES_URL` env var and skip cleanly when unset.
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
## use-cache-atomic `@warlock.js/cache/use-cache-atomic/SKILL.md`
|
|
1070
|
+
|
|
1071
|
+
---
|
|
1072
|
+
name: use-cache-atomic
|
|
1073
|
+
description: 'Atomic counters via cache.increment(key, by=1) / cache.decrement(key, by=1) — returns the new number, throws on non-numeric values. Triggers: `cache.increment`, `cache.decrement`, "view counter", "page views", "atomic counter", "decrement stock", "rate-limit counter", "INCRBY"; typical import `import { cache } from "@warlock.js/cache"`. Skip: read-modify-write of objects — `@warlock.js/cache/use-cache-update-merge/SKILL.md`; named-lock coordination — `@warlock.js/cache/use-cache-lock/SKILL.md`; competing libs `ioredis` `INCR`, native counters in a `Map`.'
|
|
1074
|
+
---
|
|
1075
|
+
|
|
1076
|
+
# Atomic counters — `cache.increment` / `cache.decrement`
|
|
1077
|
+
|
|
1078
|
+
Numeric counters that go up and down without a read-then-write race in your own
|
|
1079
|
+
code. Both return the **new** value after the operation.
|
|
1080
|
+
|
|
1081
|
+
```ts
|
|
1082
|
+
import { cache } from "@warlock.js/cache";
|
|
1083
|
+
|
|
1084
|
+
const views = await cache.increment(`post.${id}.views`); // +1 → 1, 2, 3…
|
|
1085
|
+
const bulk = await cache.increment(`post.${id}.views`, 10); // +10
|
|
1086
|
+
const left = await cache.decrement(`stock.${sku}`, 3); // -3
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
- A missing key is treated as `0`, so the first `increment` returns `by` (default `1`).
|
|
1090
|
+
- `decrement(key, n)` is exactly `increment(key, -n)`.
|
|
1091
|
+
- The stored value must be numeric — incrementing a string/object throws:
|
|
1092
|
+
`Error: Cannot increment non-numeric value for key: <key>`.
|
|
1093
|
+
|
|
1094
|
+
## Atomicity is per-driver
|
|
1095
|
+
|
|
1096
|
+
| Driver | Guarantee |
|
|
1097
|
+
|---|---|
|
|
1098
|
+
| `redis` | Native `INCRBY` / `DECRBY` — atomic **across processes/nodes** |
|
|
1099
|
+
| memory family / `file` / `pg` | Read-modify-write — atomic **within one process** only |
|
|
1100
|
+
|
|
1101
|
+
For a counter that multiple instances bump concurrently (a global rate limit, a
|
|
1102
|
+
shared tally), use the [`redis`](@warlock.js/cache/pick-cache-driver/SKILL.md)
|
|
1103
|
+
driver. In-memory counters are fine for single-node work.
|
|
1104
|
+
|
|
1105
|
+
## TTL behavior differs too
|
|
1106
|
+
|
|
1107
|
+
This is the gotcha to remember:
|
|
1108
|
+
|
|
1109
|
+
- **Redis** `INCRBY` **preserves** the key's existing TTL.
|
|
1110
|
+
- **Memory-family / pg** write the new value through `set()` with the driver's
|
|
1111
|
+
**default** TTL — they do **not** carry over the previous entry's remaining TTL.
|
|
1112
|
+
|
|
1113
|
+
So if you need a counter that expires (a fixed window), set the TTL explicitly
|
|
1114
|
+
when you create it and don't rely on `increment` to keep a window alive on the
|
|
1115
|
+
in-memory drivers. For a value that should keep its TTL across edits, reach for
|
|
1116
|
+
[`cache.update`](@warlock.js/cache/use-cache-update-merge/SKILL.md), which
|
|
1117
|
+
preserves the remaining TTL.
|
|
1118
|
+
|
|
1119
|
+
## Common shapes
|
|
1120
|
+
|
|
1121
|
+
```ts
|
|
1122
|
+
// View counter
|
|
1123
|
+
await cache.increment(`post.${id}.views`);
|
|
1124
|
+
|
|
1125
|
+
// Decrement stock, guard against oversell
|
|
1126
|
+
const remaining = await cache.decrement(`stock.${sku}`, qty);
|
|
1127
|
+
if (remaining < 0) {
|
|
1128
|
+
await cache.increment(`stock.${sku}`, qty); // roll back
|
|
1129
|
+
throw new Error("Out of stock");
|
|
1130
|
+
}
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
## See also
|
|
1134
|
+
|
|
1135
|
+
- [`@warlock.js/cache/use-cache-update-merge/SKILL.md`](@warlock.js/cache/use-cache-update-merge/SKILL.md) — atomic read-modify-write for objects, TTL-preserving
|
|
1136
|
+
- [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md) — coordinate multi-step critical sections
|
|
1137
|
+
- [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — when you need cross-node atomicity
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
## use-cache-bulk `@warlock.js/cache/use-cache-bulk/SKILL.md`
|
|
1141
|
+
|
|
1142
|
+
---
|
|
1143
|
+
name: use-cache-bulk
|
|
1144
|
+
description: 'Bulk reads/writes via cache.many(keys[]) → values[] (nulls for misses, order preserved) and cache.setMany(record, ttl?) → void. Triggers: `cache.many`, `cache.setMany`, "get multiple keys at once", "batch read cache", "warm the cache", "preload many keys", "mget", "mset"; typical import `import { cache } from "@warlock.js/cache"`. Skip: tag-based bulk invalidation — `@warlock.js/cache/use-cache-tags/SKILL.md`; single-key ops — `@warlock.js/cache/cache-basics/SKILL.md`; competing libs `ioredis` `MGET`/`MSET`.'
|
|
1145
|
+
---
|
|
1146
|
+
|
|
1147
|
+
# Bulk operations — `cache.many` / `cache.setMany`
|
|
1148
|
+
|
|
1149
|
+
Read or write a batch of keys in one call instead of awaiting them one at a time.
|
|
1150
|
+
|
|
1151
|
+
## Read many — `many(keys)`
|
|
1152
|
+
|
|
1153
|
+
```ts
|
|
1154
|
+
import { cache } from "@warlock.js/cache";
|
|
1155
|
+
|
|
1156
|
+
const [alice, bob, carol] = await cache.many(["user.1", "user.2", "user.3"]);
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
- Returns an array **positionally aligned** with `keys`.
|
|
1160
|
+
- Missing keys come back as `null` (same as `get`), so the result length always
|
|
1161
|
+
equals the input length — zip them back together by index.
|
|
1162
|
+
|
|
1163
|
+
```ts
|
|
1164
|
+
const ids = [1, 2, 3];
|
|
1165
|
+
const users = await cache.many(ids.map((id) => `user.${id}`));
|
|
1166
|
+
|
|
1167
|
+
const missingIds = ids.filter((_, index) => users[index] === null);
|
|
1168
|
+
// fetch only the misses from the origin…
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
## Write many — `setMany(items, ttl?)`
|
|
1172
|
+
|
|
1173
|
+
```ts
|
|
1174
|
+
await cache.setMany({
|
|
1175
|
+
"user.1": alice,
|
|
1176
|
+
"user.2": bob,
|
|
1177
|
+
"user.3": carol,
|
|
1178
|
+
}, 3600); // optional TTL (seconds) applied to every entry
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
- Keys are the object keys; values are the object values.
|
|
1182
|
+
- The optional second arg is a single TTL (seconds) applied to **all** entries —
|
|
1183
|
+
there's no per-entry TTL or `tags` knob here. When you need tags or mixed TTLs,
|
|
1184
|
+
loop with [`cache.set`](@warlock.js/cache/configure-set-options/SKILL.md) and
|
|
1185
|
+
the rich options object instead.
|
|
1186
|
+
|
|
1187
|
+
## Performance note
|
|
1188
|
+
|
|
1189
|
+
On every driver these run their underlying `get`/`set` calls **concurrently**
|
|
1190
|
+
(`Promise.all`) on the shared connection; on the memory family it's effectively
|
|
1191
|
+
instant. There's no partial-failure handling — if one write rejects, the
|
|
1192
|
+
returned promise rejects.
|
|
1193
|
+
|
|
1194
|
+
## See also
|
|
1195
|
+
|
|
1196
|
+
- [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md) — single-key `get` / `set` / `remember`
|
|
1197
|
+
- [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — per-entry TTL, tags, conflict policy
|
|
1198
|
+
- [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md) — invalidate a batch by tag
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
## use-cache-list `@warlock.js/cache/use-cache-list/SKILL.md`
|
|
1202
|
+
|
|
1203
|
+
---
|
|
1204
|
+
name: use-cache-list
|
|
1205
|
+
description: 'Ordered collections via cache.list<T>(key) — push / unshift / pop / shift / slice / all / length / trim / clear. Triggers: `cache.list`, `push`, `unshift`, `pop`, `shift`, `slice`, `trim`, `clear`; "job queue in cache", "keep most recent N events", "audit log buffer", "FIFO queue"; typical import `import { cache } from "@warlock.js/cache"`. Skip: locking around list writes — `@warlock.js/cache/use-cache-lock/SKILL.md`; competing libs `bullmq`, `bee-queue`, `bull`, `ioredis` `LPUSH`; native `Array.push`.'
|
|
1206
|
+
---
|
|
1207
|
+
|
|
1208
|
+
# Lists — the `cache.list<T>(key)` sub-API
|
|
1209
|
+
|
|
1210
|
+
Dedicated accessor for ordered collections — queues, recent-N buffers, sliding windows. Keeps the flat `CacheDriver` contract lean while giving list-shaped data a typed, purpose-built surface.
|
|
1211
|
+
|
|
1212
|
+
## Shape
|
|
1213
|
+
|
|
1214
|
+
```ts
|
|
1215
|
+
const recent = cache.list<Event>("recent-events");
|
|
1216
|
+
|
|
1217
|
+
await recent.push(event); // append to tail — returns new length
|
|
1218
|
+
await recent.unshift(priorityEvent); // prepend to head — returns new length
|
|
1219
|
+
const tail = await recent.pop(); // remove + return tail
|
|
1220
|
+
const head = await recent.shift(); // remove + return head
|
|
1221
|
+
const first10 = await recent.slice(0, 10); // view — does not mutate
|
|
1222
|
+
const all = await recent.all();
|
|
1223
|
+
const count = await recent.length();
|
|
1224
|
+
await recent.trim(0, 99); // keep only indices 0..99 inclusive
|
|
1225
|
+
await recent.clear();
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
## Type safety
|
|
1229
|
+
|
|
1230
|
+
The generic flows through every method. Pass the element type at the accessor call, not on each method:
|
|
1231
|
+
|
|
1232
|
+
```ts
|
|
1233
|
+
type Event = { type: string; at: number };
|
|
1234
|
+
const queue = cache.list<Event>("jobs:queue");
|
|
1235
|
+
|
|
1236
|
+
await queue.push({ type: "import", at: Date.now() }); // ✓
|
|
1237
|
+
await queue.push("not an event" as never); // ✗ at the caller
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
## Current performance characteristics
|
|
1241
|
+
|
|
1242
|
+
The default implementation (used by memory, memoryExtended, LRU, file, **and redis today**) stores the entire list as a single cache entry and does read-mutate-write on every op. Correct for every driver, O(n) per op.
|
|
1243
|
+
|
|
1244
|
+
Redis-native `LPUSH` / `RPUSH` / `LRANGE` / `LTRIM` is planned for v2.1 (see `domains/cache/backlog.md`). Until then, treat Redis list ops as O(n) and avoid very large lists on Redis.
|
|
1245
|
+
|
|
1246
|
+
## Concurrency warning
|
|
1247
|
+
|
|
1248
|
+
List writes on memory / file / LRU drivers **race** when two callers push simultaneously — the default read-mutate-write loop has no lock. If you need safe concurrent list writes today, wrap pushes in a distributed-lock pattern (see [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md)) or use a single writer.
|
|
1249
|
+
|
|
1250
|
+
Single-process memory with a single writer (typical test / script usage) is fine.
|
|
1251
|
+
|
|
1252
|
+
## Empty-list cleanup
|
|
1253
|
+
|
|
1254
|
+
When a list becomes empty (e.g. after successive `pop()` / `shift()` / `trim(0, -1)`), the backing cache entry is **removed** — `cache.get(key)` returns `null`, not `[]`. This keeps the store from accumulating empty list entries.
|
|
1255
|
+
|
|
1256
|
+
```ts
|
|
1257
|
+
await recent.push("a");
|
|
1258
|
+
await recent.pop();
|
|
1259
|
+
await cache.get("recent-events"); // null, not []
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
## Typical recipes
|
|
1263
|
+
|
|
1264
|
+
```ts
|
|
1265
|
+
// Recent-N audit log
|
|
1266
|
+
const audit = cache.list<AuditEntry>("audit:recent");
|
|
1267
|
+
await audit.unshift(entry); // newest at head
|
|
1268
|
+
await audit.trim(0, 999); // keep most-recent 1000
|
|
1269
|
+
|
|
1270
|
+
// Lightweight job queue (single-node)
|
|
1271
|
+
const queue = cache.list<Job>("jobs:pending");
|
|
1272
|
+
await queue.push(job);
|
|
1273
|
+
const next = await queue.shift(); // FIFO
|
|
1274
|
+
|
|
1275
|
+
// Stack
|
|
1276
|
+
const stack = cache.list<Frame>("stack");
|
|
1277
|
+
await stack.push(frame);
|
|
1278
|
+
const top = await stack.pop(); // LIFO
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
## What lists are NOT for
|
|
1282
|
+
|
|
1283
|
+
- Unordered uniqueness — no native set today; use a plain object/Map in memory, or roll your own via `cache.get/set`.
|
|
1284
|
+
- Hash / field maps — same; use individual keys with a shared prefix.
|
|
1285
|
+
- Ordered top-N with scoring — no sorted-set analog today.
|
|
1286
|
+
|
|
1287
|
+
These are tracked as candidates for v3.
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
## use-cache-lock `@warlock.js/cache/use-cache-lock/SKILL.md`
|
|
1291
|
+
|
|
1292
|
+
---
|
|
1293
|
+
name: use-cache-lock
|
|
1294
|
+
description: 'Distributed lock via cache.lock(key, ttl, fn) — acquire, run fn, auto-release. Returns {acquired: true, value} or {acquired: false}. Triggers: `cache.lock`, `LockOutcome`, `acquired`, `owner`; "run cron on only one server", "idempotent webhook handler", "dedup payment processing", "lock a task across nodes"; typical import `import { cache } from "@warlock.js/cache"`. Skip: raw `onConflict: "create"` recipe — `@warlock.js/cache/apply-cache-patterns/SKILL.md`; memoization — `@warlock.js/cache/use-cached-hof/SKILL.md`; competing libs `redlock`, `async-mutex`, `proper-lockfile`.'
|
|
1295
|
+
---
|
|
1296
|
+
|
|
1297
|
+
# `cache.lock()` — distributed locks with auto-release
|
|
1298
|
+
|
|
1299
|
+
`cache.lock(key, ttl, fn)` acquires a distributed lock, runs `fn`, and auto-releases (even on throw). Built on `set({ onConflict: "create" })` — Redis-native where available, emulated elsewhere.
|
|
1300
|
+
|
|
1301
|
+
## When to use
|
|
1302
|
+
|
|
1303
|
+
- A task should run on only **one server at a time** (cron jobs, imports, migrations).
|
|
1304
|
+
- Idempotent webhook or payment processing — dedup across retries.
|
|
1305
|
+
- Any time you'd otherwise write `try { … } finally { cache.remove(lockKey); }`.
|
|
1306
|
+
|
|
1307
|
+
**Not for memoization** — use [`cached()`](@warlock.js/cache/use-cached-hof/SKILL.md) or `cache.remember()`.
|
|
1308
|
+
|
|
1309
|
+
## Shape
|
|
1310
|
+
|
|
1311
|
+
```ts
|
|
1312
|
+
// Primary — positional TTL
|
|
1313
|
+
await cache.lock(key, ttl, fn);
|
|
1314
|
+
|
|
1315
|
+
// With options — owner for debugging, per-call driver override
|
|
1316
|
+
await cache.lock(key, { ttl, owner?, driver? }, fn);
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
**TTL is required.** Forgotten locks stay forever if the process crashes; the TTL is your safety net.
|
|
1320
|
+
|
|
1321
|
+
## Return shape — discriminated union
|
|
1322
|
+
|
|
1323
|
+
```ts
|
|
1324
|
+
type LockOutcome<T> =
|
|
1325
|
+
| { acquired: true; value: T }
|
|
1326
|
+
| { acquired: false };
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
Unambiguous even when `fn` returns `undefined`. Narrow with TS:
|
|
1330
|
+
|
|
1331
|
+
```ts
|
|
1332
|
+
const outcome = await cache.lock("lock.x", "1m", async () => compute());
|
|
1333
|
+
|
|
1334
|
+
if (outcome.acquired) {
|
|
1335
|
+
console.log(outcome.value); // typed
|
|
1336
|
+
} else {
|
|
1337
|
+
console.log("someone else is running");
|
|
1338
|
+
}
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
## Recipes
|
|
1342
|
+
|
|
1343
|
+
### Cron on only one server
|
|
1344
|
+
|
|
1345
|
+
```ts
|
|
1346
|
+
cron.daily("3am", () =>
|
|
1347
|
+
cache.lock("lock.cleanup", "30m", () => db.cleanup()),
|
|
1348
|
+
);
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
### Idempotent webhook
|
|
1352
|
+
|
|
1353
|
+
```ts
|
|
1354
|
+
app.post("/webhooks/stripe", async (req, res) => {
|
|
1355
|
+
const outcome = await cache.lock(
|
|
1356
|
+
`webhook.stripe.${req.body.id}`,
|
|
1357
|
+
"24h",
|
|
1358
|
+
() => processStripeEvent(req.body),
|
|
1359
|
+
);
|
|
1360
|
+
|
|
1361
|
+
if (!outcome.acquired) {
|
|
1362
|
+
return res.status(200).json({ status: "already-processed" });
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
res.status(200).json({ status: "processed" });
|
|
1366
|
+
});
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
### Batch job with debug-friendly owner
|
|
1370
|
+
|
|
1371
|
+
```ts
|
|
1372
|
+
await cache.lock(
|
|
1373
|
+
`lock.report.${date}`,
|
|
1374
|
+
{ ttl: "1h", owner: `worker.${process.env.HOSTNAME}` },
|
|
1375
|
+
() => generateReport(date),
|
|
1376
|
+
);
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
`await cache.get("lock.report.2026-04-24")` reveals which worker holds the lock.
|
|
1380
|
+
|
|
1381
|
+
## Driver behavior
|
|
1382
|
+
|
|
1383
|
+
| Driver | Cross-process safe? |
|
|
1384
|
+
|--------|:-:|
|
|
1385
|
+
| `redis` | ✅ Native `SET … NX EX` |
|
|
1386
|
+
| `memory` / `memoryExtended` / `lru` | ❌ In-process only |
|
|
1387
|
+
| `file` | ⚠️ Single-host only (races across hosts) |
|
|
1388
|
+
| `null` | n/a — always "acquires" |
|
|
1389
|
+
|
|
1390
|
+
## Gotchas
|
|
1391
|
+
|
|
1392
|
+
- **Non-re-entrant in v1.** A recursive call for the same key gets `{ acquired: false }`.
|
|
1393
|
+
- **Don't release inside `fn`.** `lock()` handles release in `finally`. Manual `cache.remove(lockKey)` inside `fn` would let another process jump in mid-work.
|
|
1394
|
+
- **TTL shorter than `fn` runtime = race.** Pick a TTL with generous margin.
|
|
1395
|
+
- **Cross-server requires Redis.** Memory / LRU drivers don't coordinate across processes.
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
## use-cache-namespace `@warlock.js/cache/use-cache-namespace/SKILL.md`
|
|
1399
|
+
|
|
1400
|
+
---
|
|
1401
|
+
name: use-cache-namespace
|
|
1402
|
+
description: 'Scope cache keys via cache.namespace(prefix, options?) — every key auto-prefixed, scope-level ttl / tags defaults, nested scopes, .clear() sugar. Triggers: `cache.namespace`, `cache.removeNamespace`, `clear`, `globalPrefix`; "scope cache keys under a prefix", "share TTL across a whole prefix", "drop every key under user.1", "nested cache scopes"; typical import `import { cache } from "@warlock.js/cache"`. Skip: tag-based bulk drop — `@warlock.js/cache/use-cache-tags/SKILL.md`; multi-tenant driver-level prefix — `@warlock.js/cache/pick-cache-driver/SKILL.md`; SWR — `@warlock.js/cache/use-swr/SKILL.md`; competing libs `keyv` namespaces.'
|
|
1403
|
+
---
|
|
1404
|
+
|
|
1405
|
+
# Scoped caches — `cache.namespace(prefix, options?)`
|
|
1406
|
+
|
|
1407
|
+
When you'll touch the same prefix more than a couple of times, grab a scoped handle instead of repeating the prefix at every call site.
|
|
1408
|
+
|
|
1409
|
+
## Shape
|
|
1410
|
+
|
|
1411
|
+
```ts
|
|
1412
|
+
const chat = cache.namespace(`chats.${id}`, { ttl: "30d", tags: [`user.${userId}`] });
|
|
1413
|
+
|
|
1414
|
+
await chat.set("messages.10", msg); // chats.<id>.messages.10, 30d, user.<userId>
|
|
1415
|
+
await chat.set("draft", d, { ttl: "1h" }); // per-call ttl wins
|
|
1416
|
+
await chat.tags(["unread"]).set("ping", p); // tags merge: user.<userId> + unread
|
|
1417
|
+
await chat.clear(); // sugar for removeNamespace
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
Scopes are pure views — same connection, same driver, no extra state. Per-call options always win over scope defaults; tags merge additively.
|
|
1421
|
+
|
|
1422
|
+
## Nested scopes
|
|
1423
|
+
|
|
1424
|
+
```ts
|
|
1425
|
+
const chat = cache.namespace(`chats.${id}`, { ttl: "30d" });
|
|
1426
|
+
const typing = chat.namespace("typing", { ttl: "5s" }); // overrides parent ttl
|
|
1427
|
+
|
|
1428
|
+
await typing.set("user.42", true); // chats.<id>.typing.user.42, 5s
|
|
1429
|
+
```
|
|
1430
|
+
|
|
1431
|
+
Nested scopes inherit defaults and can override. Tags accumulate.
|
|
1432
|
+
|
|
1433
|
+
## When to reach for it
|
|
1434
|
+
|
|
1435
|
+
- The prefix repeats more than 2–3 times.
|
|
1436
|
+
- A whole prefix shares a TTL or tag policy.
|
|
1437
|
+
- You want `.clear()` to read like the intent ("clear this chat") instead of `removeNamespace(...)` boilerplate.
|
|
1438
|
+
|
|
1439
|
+
Inline prefixes are still fine for one-off writes.
|
|
1440
|
+
|
|
1441
|
+
## Plain `removeNamespace` when you already have the prefix
|
|
1442
|
+
|
|
1443
|
+
When you *do* know the prefix string and don't need a scoped handle for repeated reads/writes:
|
|
1444
|
+
|
|
1445
|
+
```ts
|
|
1446
|
+
await cache.set("user:1:profile", profile);
|
|
1447
|
+
await cache.set("user:1:prefs", prefs);
|
|
1448
|
+
await cache.set("user:2:profile", otherProfile);
|
|
1449
|
+
|
|
1450
|
+
await cache.removeNamespace("user.1"); // drops both user:1 entries, keeps user:2
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
Cheaper than tags (no reverse index to maintain). Every real driver supports it — memory family and `lru` by prefix-scan, `file` by directory, `redis`/`pg` by key/`LIKE` prefix; `null` no-ops. See [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md).
|
|
1454
|
+
|
|
1455
|
+
## Multi-tenant scoping at the driver level
|
|
1456
|
+
|
|
1457
|
+
Instead of every call passing a tenant prefix, attach `globalPrefix` to the driver config — see [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md). Function form runs per call.
|
|
1458
|
+
|
|
1459
|
+
```ts
|
|
1460
|
+
options: {
|
|
1461
|
+
redis: {
|
|
1462
|
+
url: "...",
|
|
1463
|
+
globalPrefix: () => `tenant-${currentContext.tenantId}`,
|
|
1464
|
+
},
|
|
1465
|
+
}
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
## SWR + namespace
|
|
1469
|
+
|
|
1470
|
+
```ts
|
|
1471
|
+
const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
|
|
1472
|
+
|
|
1473
|
+
await feed.swr(
|
|
1474
|
+
"home",
|
|
1475
|
+
{ freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
|
|
1476
|
+
() => buildHomeFeed(userId),
|
|
1477
|
+
);
|
|
1478
|
+
// stored at feed.<userId>.home, tagged [user.<userId>, computed]
|
|
1479
|
+
```
|
|
1480
|
+
|
|
1481
|
+
Note: scope `ttl` defaults are NOT applied to SWR — `freshTtl` / `staleTtl` always come from the call site. See [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md).
|
|
1482
|
+
|
|
1483
|
+
## Things NOT to do
|
|
1484
|
+
|
|
1485
|
+
- Don't create a `cache.namespace(prefix)` for a single read/write — the boilerplate doesn't pay off until the prefix repeats. Inline `cache.set("prefix.foo", ...)` is fine.
|
|
1486
|
+
- Don't expect `cache.namespace(prefix).clear()` to do anything on the `null` driver — `removeNamespace` no-ops there (it caches nothing).
|
|
1487
|
+
- Don't mix prefix separators. The convention is `.` (dot) — pick one and stick with it across scopes so nested prefixes compose predictably.
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
## use-cache-similarity `@warlock.js/cache/use-cache-similarity/SKILL.md`
|
|
1491
|
+
|
|
1492
|
+
---
|
|
1493
|
+
name: use-cache-similarity
|
|
1494
|
+
description: 'Vector retrieval via cache.similar(vector, {topK, threshold?, tags?}) — index with set(k, v, {vector}), query nearest by cosine similarity. Triggers: `cache.similar`, `cache.set` with `vector`, `topK`, `threshold`, `tags`, `cosineSimilarity`; "build a semantic cache for an LLM", "RAG retrieval from cache", "nearest-neighbor over cached entries", "skip the LLM when a similar answer exists"; typical import `import { cache } from "@warlock.js/cache"`. Skip: pgvector setup specifics — `@warlock.js/cache/configure-pg-cache/SKILL.md`; competing libs `pinecone`, `weaviate`, `chromadb`, `lancedb`, `faiss-node`.'
|
|
1495
|
+
---
|
|
1496
|
+
|
|
1497
|
+
# `cache.similar()` — vector-based retrieval
|
|
1498
|
+
|
|
1499
|
+
`cache.similar(vector, { topK, threshold?, tags? })` returns stored entries closest to `vector` by cosine similarity, ordered descending by score. Same `set` / `get` model — different lookup function.
|
|
1500
|
+
|
|
1501
|
+
## Shape
|
|
1502
|
+
|
|
1503
|
+
```ts
|
|
1504
|
+
// Index on the way in.
|
|
1505
|
+
await cache.set(key, value, { vector: number[], tags?, ttl? });
|
|
1506
|
+
|
|
1507
|
+
// Query.
|
|
1508
|
+
const hits = await cache.similar<T>(queryVec, {
|
|
1509
|
+
topK: number, // required
|
|
1510
|
+
threshold?: number, // [0, 1]; hits below are dropped
|
|
1511
|
+
tags?: string[], // narrow candidate pool by tag (union)
|
|
1512
|
+
});
|
|
1513
|
+
// hits: { key: string; value: T; score: number }[] // score in [0, 1]
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
## Capability matrix
|
|
1517
|
+
|
|
1518
|
+
| Driver | `similar()` |
|
|
1519
|
+
|---|---|
|
|
1520
|
+
| `memory` / `memoryExtended` / `lru` | ✅ Brute force (O(N)) — dev only past ~10k entries |
|
|
1521
|
+
| `pg` *with* `options.pg.vector` | ✅ pgvector + HNSW/IVFFlat index |
|
|
1522
|
+
| `pg` *without* `options.pg.vector` | ❌ Throws `CacheUnsupportedError` |
|
|
1523
|
+
| `redis` | ❌ Throws (RediSearch on backlog) |
|
|
1524
|
+
| `file` | ❌ Throws |
|
|
1525
|
+
| `null` | Returns `[]` |
|
|
1526
|
+
|
|
1527
|
+
## Always-true facts
|
|
1528
|
+
|
|
1529
|
+
1. **Cache is embedding-agnostic.** Caller computes vectors. The cache stores and ranks; it doesn't call out to an embedder.
|
|
1530
|
+
2. **Only entries written with `set({ vector })` show up.** A plain `set` adds the entry as KV — invisible to `similar()`.
|
|
1531
|
+
3. **Score = cosine similarity** in `[0, 1]` for typical embedding spaces. The `pg` driver computes `1 - (embedding <=> $1::vector)` so the score matches the memory drivers.
|
|
1532
|
+
4. **Tag filter narrows the candidate pool *before* ranking** — union semantics (entry must carry at least one of the listed tags).
|
|
1533
|
+
5. **Dimension mismatch throws `CacheConfigurationError`** at both `set({ vector })` and `similar()` time. Don't switch embedders without re-indexing.
|
|
1534
|
+
6. **TTL + LRU eviction also drop the vector** — expired or evicted entries are invisible to `similar()`.
|
|
1535
|
+
|
|
1536
|
+
## Recipes
|
|
1537
|
+
|
|
1538
|
+
### Semantic cache for an LLM
|
|
1539
|
+
|
|
1540
|
+
```ts
|
|
1541
|
+
const queryVec = await embed(prompt);
|
|
1542
|
+
const hits = await cache.similar<Answer>(queryVec, { topK: 1, threshold: 0.92 });
|
|
1543
|
+
|
|
1544
|
+
if (hits.length > 0) {
|
|
1545
|
+
return hits[0].value; // skip the LLM call
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const answer = await llm.complete(prompt);
|
|
1549
|
+
await cache.set(`q.${hash(prompt)}`, answer, {
|
|
1550
|
+
vector: queryVec,
|
|
1551
|
+
ttl: "30d",
|
|
1552
|
+
tags: ["llm-cache"],
|
|
1553
|
+
});
|
|
1554
|
+
return answer;
|
|
1555
|
+
```
|
|
1556
|
+
|
|
1557
|
+
### Tag-narrowed RAG
|
|
1558
|
+
|
|
1559
|
+
```ts
|
|
1560
|
+
const hits = await cache.similar<Doc>(await embed(question), {
|
|
1561
|
+
topK: 5,
|
|
1562
|
+
threshold: 0.7,
|
|
1563
|
+
tags: ["docs", `tenant.${tenantId}`],
|
|
1564
|
+
});
|
|
1565
|
+
```
|
|
1566
|
+
|
|
1567
|
+
### Production swap — same code, different driver
|
|
1568
|
+
|
|
1569
|
+
```ts
|
|
1570
|
+
// Dev:
|
|
1571
|
+
options: { memory: { ttl: "1h" } }
|
|
1572
|
+
|
|
1573
|
+
// Prod — same set/similar calls; index now lives in pgvector:
|
|
1574
|
+
options: { pg: { client: pool, vector: { dimensions: 1536 } } }
|
|
1575
|
+
```
|
|
1576
|
+
|
|
1577
|
+
See [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md).
|
|
1578
|
+
|
|
1579
|
+
## Things NOT to do
|
|
1580
|
+
|
|
1581
|
+
- Don't use `cache.similar()` on a memory driver with 100k+ vectorized entries — it scales O(N) per query. Switch to `pg` with `vector` config.
|
|
1582
|
+
- Don't pass an empty array as `vector` — `cosineSimilarity` throws `CacheConfigurationError`.
|
|
1583
|
+
- Don't mix vector dimensions in the same driver — re-embed when models change.
|
|
1584
|
+
- Don't expect `similar()` to surface a missing vector (`set` without the `vector` option). Plain KV entries stay out of the similarity index.
|
|
1585
|
+
- Don't use `topK: 0` or negative — `pg` rejects with `CacheConfigurationError`; memory drivers return `[]` but it's a code smell.
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
## use-cache-tags `@warlock.js/cache/use-cache-tags/SKILL.md`
|
|
1589
|
+
|
|
1590
|
+
---
|
|
1591
|
+
name: use-cache-tags
|
|
1592
|
+
description: 'Tag-based invalidation — attach tags on write, then cache.tags([...]).invalidate() drops every key bound to any of those tags. Triggers: `cache.tags`, `invalidate`, `cache.set` with `tags`; "invalidate every key tagged users", "drop everything for tenant 42", "bulk cache invalidation without knowing keys", "tag a cached value"; typical import `import { cache } from "@warlock.js/cache"`. Skip: prefix-based drop — `@warlock.js/cache/use-cache-namespace/SKILL.md`; HOF memoization with tags — `@warlock.js/cache/use-cached-hof/SKILL.md`; SWR — `@warlock.js/cache/use-swr/SKILL.md`; competing libs `cache-manager` tags, Next.js `revalidateTag`.'
|
|
1593
|
+
---
|
|
1594
|
+
|
|
1595
|
+
# Tag-based invalidation
|
|
1596
|
+
|
|
1597
|
+
Tags let you invalidate by a label rather than by enumerating every key. Use them when the set of keys to invalidate is not known ahead of time.
|
|
1598
|
+
|
|
1599
|
+
## Attach tags on write
|
|
1600
|
+
|
|
1601
|
+
```ts
|
|
1602
|
+
// Inline — terser when you know the tags up front
|
|
1603
|
+
await cache.set("user:1:profile", profile, { tags: ["users", "tenant-42"] });
|
|
1604
|
+
await cache.set("user:1:prefs", prefs, { tags: ["users", "tenant-42"] });
|
|
1605
|
+
|
|
1606
|
+
// Fluent — useful when you already have a tagged handle
|
|
1607
|
+
const users = cache.tags(["users"]);
|
|
1608
|
+
await users.set("user:1", user);
|
|
1609
|
+
await users.set("user:2", otherUser);
|
|
1610
|
+
```
|
|
1611
|
+
|
|
1612
|
+
## Invalidate
|
|
1613
|
+
|
|
1614
|
+
```ts
|
|
1615
|
+
// Drop everything tagged "users"
|
|
1616
|
+
await cache.tags(["users"]).invalidate();
|
|
1617
|
+
|
|
1618
|
+
// Multi-tag — matches either tag (union)
|
|
1619
|
+
await cache.tags(["tenant-42"]).invalidate();
|
|
1620
|
+
```
|
|
1621
|
+
|
|
1622
|
+
Multi-tag is **union** semantics: an entry is invalidated if it carries **at least one** of the listed tags.
|
|
1623
|
+
|
|
1624
|
+
## When to reach for tags vs namespaces
|
|
1625
|
+
|
|
1626
|
+
| Use case | Reach for |
|
|
1627
|
+
| --- | --- |
|
|
1628
|
+
| The keys share a known prefix | `cache.removeNamespace("prefix")` ([`use-cache-namespace`](@warlock.js/cache/use-cache-namespace/SKILL.md)) |
|
|
1629
|
+
| The keys are spread across prefixes, tied by entity | Tags |
|
|
1630
|
+
| Both apply | Tags — more flexible; cheap on most drivers |
|
|
1631
|
+
|
|
1632
|
+
Namespaces are cheaper (no reverse index). Tags are more powerful (any key can carry any tag).
|
|
1633
|
+
|
|
1634
|
+
## Inline tag semantics
|
|
1635
|
+
|
|
1636
|
+
Subsequent `set("user:1", ...)` with **no** `tags` leaves previous associations intact (the tag index still points to the key), but a `set(..., { tags: [...] })` adds to whatever index entries already exist rather than removing old tag bindings. Tag associations are additive at write-time.
|
|
1637
|
+
|
|
1638
|
+
## Driver behavior
|
|
1639
|
+
|
|
1640
|
+
| Driver | Tag invalidation |
|
|
1641
|
+
| --- | :-: |
|
|
1642
|
+
| `null` | noop |
|
|
1643
|
+
| `memory` / `memoryExtended` / `lru` / `file` | ✓ (reverse index in driver state) |
|
|
1644
|
+
| `redis` | ✓ |
|
|
1645
|
+
| `pg` | ✓ native via `GIN(tags)` index |
|
|
1646
|
+
|
|
1647
|
+
## SWR with tags
|
|
1648
|
+
|
|
1649
|
+
```ts
|
|
1650
|
+
await cache.swr(
|
|
1651
|
+
`product.${id}`,
|
|
1652
|
+
{ freshTtl: "1m", staleTtl: "1h", tags: ["products", `tenant.${tenantId}`] },
|
|
1653
|
+
() => db.products.find(id),
|
|
1654
|
+
);
|
|
1655
|
+
```
|
|
1656
|
+
|
|
1657
|
+
Tags re-apply on every successful refresh — see [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md).
|
|
1658
|
+
|
|
1659
|
+
## `cached()` HOF with tags
|
|
1660
|
+
|
|
1661
|
+
```ts
|
|
1662
|
+
const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
|
|
1663
|
+
const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
|
|
1664
|
+
|
|
1665
|
+
await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
See [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md).
|
|
1669
|
+
|
|
1670
|
+
## Things NOT to do
|
|
1671
|
+
|
|
1672
|
+
- Don't tag aggressively. A reverse index per tag is cheap but not free — pick tags that actually correspond to invalidation events.
|
|
1673
|
+
- Don't expect tag invalidation to fire events for each affected key. It's a bulk op; the event bus emits one `flushed` or per-key `removed` per driver implementation. Test by reading back, not by counting events.
|
|
1674
|
+
- Don't use tags as a query mechanism. Tags drop keys; they don't list them. If you need "give me every user", store an index list separately.
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
## use-cache-update-merge `@warlock.js/cache/use-cache-update-merge/SKILL.md`
|
|
1678
|
+
|
|
1679
|
+
---
|
|
1680
|
+
name: use-cache-update-merge
|
|
1681
|
+
description: 'Atomic read-modify-write via cache.update(key, fn) (callback receives current) or cache.merge(key, partial) (shallow merge). Per-key chain lock serializes concurrent in-process callers. Triggers: `cache.update`, `cache.merge`, `cache.increment`, `cache.pull`; "atomically update a cached counter", "change one field on a cached object", "avoid get-spread-set race", "serialize concurrent cache writers"; typical import `import { cache } from "@warlock.js/cache"`. Skip: cross-process locking — `@warlock.js/cache/use-cache-lock/SKILL.md`; conditional create/update — `@warlock.js/cache/configure-set-options/SKILL.md`; competing libs `lodash.merge`, raw redis `WATCH`/`MULTI`.'
|
|
1682
|
+
---
|
|
1683
|
+
|
|
1684
|
+
# `update` and `merge` — atomic read-modify-write
|
|
1685
|
+
|
|
1686
|
+
Prefer these over the ad-hoc `get → spread → set` pattern. Each call takes a per-key chain lock so concurrent callers in the same process are serialized end-to-end.
|
|
1687
|
+
|
|
1688
|
+
## `update(key, fn, options?)`
|
|
1689
|
+
|
|
1690
|
+
Read the current value, pass it to `fn`, write what `fn` returns.
|
|
1691
|
+
|
|
1692
|
+
```ts
|
|
1693
|
+
// Counter increment
|
|
1694
|
+
await cache.update<number>("views", (current) => (current ?? 0) + 1);
|
|
1695
|
+
|
|
1696
|
+
// Update nested state with defaults
|
|
1697
|
+
await cache.update<UserState>("user:1:state", (current) => ({
|
|
1698
|
+
...(current ?? defaultState),
|
|
1699
|
+
lastSeenAt: Date.now(),
|
|
1700
|
+
}));
|
|
1701
|
+
|
|
1702
|
+
// Conditional update — return null to remove
|
|
1703
|
+
await cache.update<Session>("session:abc", (current) => {
|
|
1704
|
+
if (!current || current.expired) {
|
|
1705
|
+
return null; // removes the key
|
|
1706
|
+
}
|
|
1707
|
+
return { ...current, extendedAt: Date.now() };
|
|
1708
|
+
});
|
|
1709
|
+
```
|
|
1710
|
+
|
|
1711
|
+
- `fn` receives `current: T | null`. Missing keys are `null`, not an exception.
|
|
1712
|
+
- Returning `null` **removes** the entry.
|
|
1713
|
+
- TTL is preserved by default. To reset, pass `{ ttl: "1h" }` as the 3rd arg.
|
|
1714
|
+
|
|
1715
|
+
## `merge(key, partial, options?)`
|
|
1716
|
+
|
|
1717
|
+
Shallow-merge sugar for the common "update one field" shape:
|
|
1718
|
+
|
|
1719
|
+
```ts
|
|
1720
|
+
await cache.merge<User>("user:1", { name: "Jane" });
|
|
1721
|
+
await cache.merge<User>("user:1", { lastSeenAt: Date.now() }, { ttl: "1h" });
|
|
1722
|
+
```
|
|
1723
|
+
|
|
1724
|
+
- **Shallow only.** Arrays are replaced wholesale. Nested objects overwrite.
|
|
1725
|
+
- Missing key → treats current as `{}`, creates with the partial.
|
|
1726
|
+
- Preserves existing TTL unless the options override is passed.
|
|
1727
|
+
|
|
1728
|
+
Deep merge is not built in by design — too many edge cases with arrays and nullish values. If you need deep, write a custom `update(key, deepMerge(current, partial))`.
|
|
1729
|
+
|
|
1730
|
+
## What you can't do
|
|
1731
|
+
|
|
1732
|
+
- No JSONPath / dot-path partial updates (`update(key, "profile.name", "Jane")`). Use the callback form.
|
|
1733
|
+
- No file-driver support — both methods throw `CacheUnsupportedError` there. Use memory or redis.
|
|
1734
|
+
- No cross-process safety yet on Redis. The chain lock is in-process only. Cross-process safety requires `WATCH`/`MULTI` (tracked in `domains/cache/backlog.md` as a v2.1 follow-up). If two nodes run `update` on the same key simultaneously, last-write-wins.
|
|
1735
|
+
|
|
1736
|
+
## Concurrent-in-process correctness
|
|
1737
|
+
|
|
1738
|
+
```ts
|
|
1739
|
+
await cache.set("counter", 0);
|
|
1740
|
+
|
|
1741
|
+
// 10 concurrent increments, all on the same key — all serialize
|
|
1742
|
+
await Promise.all(
|
|
1743
|
+
Array.from({ length: 10 }, () =>
|
|
1744
|
+
cache.update<number>("counter", (current) => (current ?? 0) + 1),
|
|
1745
|
+
),
|
|
1746
|
+
);
|
|
1747
|
+
|
|
1748
|
+
await cache.get("counter"); // 10 — not a lost-update
|
|
1749
|
+
```
|
|
1750
|
+
|
|
1751
|
+
The per-key lock map lives on the driver instance and is cleared after each chain link finishes. No leak.
|
|
1752
|
+
|
|
1753
|
+
## When to reach for what
|
|
1754
|
+
|
|
1755
|
+
| Task | Use |
|
|
1756
|
+
| --- | --- |
|
|
1757
|
+
| "Add 1 to this counter" | `increment()` (Redis-atomic via `INCRBY`; in-process elsewhere) |
|
|
1758
|
+
| "Change one field on a cached object" | `merge()` |
|
|
1759
|
+
| "Read-decide-maybe-write, possibly remove" | `update()` |
|
|
1760
|
+
| "Only set if missing" | `set(k, v, { onConflict: "create" })` |
|
|
1761
|
+
| "Only set if already exists" | `set(k, v, { onConflict: "update" })` |
|
|
1762
|
+
| "Read-then-delete atomically" | `pull()` |
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
## use-cache-utils `@warlock.js/cache/use-cache-utils/SKILL.md`
|
|
1766
|
+
|
|
1767
|
+
---
|
|
1768
|
+
name: use-cache-utils
|
|
1769
|
+
description: 'Low-level cache utilities re-exported from @warlock.js/cache — parseTtl, expiresAtToTtl, resolveTtl, normalizeToOptions, normalizeToRememberOptions, parseCacheKey, mergeTagSets, injectTags, cosineSimilarity, and the CACHE_FOR TTL enum. Triggers: `parseTtl`, `parseCacheKey`, `resolveTtl`, `expiresAtToTtl`, `cosineSimilarity`, `mergeTagSets`, `injectTags`, `CACHE_FOR`; "parse a duration to seconds", "build a cache key from an object", "score two vectors", "common TTL constant"; typical import `import { parseTtl, CACHE_FOR } from "@warlock.js/cache"`. Skip: building a whole custom driver — `@warlock.js/cache/pick-cache-driver/SKILL.md`; the high-level set API — `@warlock.js/cache/configure-set-options/SKILL.md`.'
|
|
1770
|
+
---
|
|
1771
|
+
|
|
1772
|
+
# Cache utilities
|
|
1773
|
+
|
|
1774
|
+
The helpers the drivers are built from, all re-exported from
|
|
1775
|
+
`@warlock.js/cache`. You rarely need them at the call site — `cache.set("k", v,
|
|
1776
|
+
"1h")` parses the duration for you. They earn their keep when you write a custom
|
|
1777
|
+
driver or do cache-adjacent work outside the manager.
|
|
1778
|
+
|
|
1779
|
+
## TTL helpers
|
|
1780
|
+
|
|
1781
|
+
```ts
|
|
1782
|
+
import { parseTtl, expiresAtToTtl, resolveTtl } from "@warlock.js/cache";
|
|
1783
|
+
|
|
1784
|
+
parseTtl(3600); // 3600
|
|
1785
|
+
parseTtl("1h"); // 3600 (duration string via `ms`)
|
|
1786
|
+
parseTtl(Infinity); // Infinity (no expiry)
|
|
1787
|
+
parseTtl(-5); // throws CacheConfigurationError
|
|
1788
|
+
|
|
1789
|
+
expiresAtToTtl(new Date(Date.now() + 60_000)); // ~60 (absolute → relative seconds)
|
|
1790
|
+
expiresAtToTtl(Date.now() - 1000); // throws — deadline in the past
|
|
1791
|
+
|
|
1792
|
+
// caller ttl > expiresAt > fallback; ttl+expiresAt together throws
|
|
1793
|
+
resolveTtl("1h", undefined, Infinity); // 3600
|
|
1794
|
+
resolveTtl(undefined, undefined, 1800); // 1800 (fallback)
|
|
1795
|
+
```
|
|
1796
|
+
|
|
1797
|
+
## Option normalizers
|
|
1798
|
+
|
|
1799
|
+
Coerce the polymorphic 2nd/3rd argument of `set`/`remember` into a uniform shape
|
|
1800
|
+
— what `BaseCacheDriver` uses internally:
|
|
1801
|
+
|
|
1802
|
+
```ts
|
|
1803
|
+
import { normalizeToOptions, normalizeToRememberOptions } from "@warlock.js/cache";
|
|
1804
|
+
|
|
1805
|
+
normalizeToOptions(60); // { ttl: 60 }
|
|
1806
|
+
normalizeToOptions("1h"); // { ttl: "1h" }
|
|
1807
|
+
normalizeToOptions({ tags: ["x"] }); // returned as-is
|
|
1808
|
+
normalizeToRememberOptions("1h"); // { ttl: "1h" } (no expiresAt/onConflict)
|
|
1809
|
+
```
|
|
1810
|
+
|
|
1811
|
+
## Key + tag helpers
|
|
1812
|
+
|
|
1813
|
+
```ts
|
|
1814
|
+
import { parseCacheKey, mergeTagSets, injectTags } from "@warlock.js/cache";
|
|
1815
|
+
|
|
1816
|
+
parseCacheKey("users:1"); // "users.1"
|
|
1817
|
+
parseCacheKey({ page: 1, q: "John" }); // "page.1.q.John"
|
|
1818
|
+
parseCacheKey("user:1", { globalPrefix: "app" }); // "app.user.1"
|
|
1819
|
+
|
|
1820
|
+
mergeTagSets(["a", "b"], ["b", "c"]); // ["a","b","c"] (deduped union)
|
|
1821
|
+
mergeTagSets(undefined, undefined); // undefined
|
|
1822
|
+
|
|
1823
|
+
injectTags({ ttl: "1h" }, ["unread"]); // { ttl: "1h", tags: ["unread"] } (pure, no mutation)
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
## Vector scoring
|
|
1827
|
+
|
|
1828
|
+
```ts
|
|
1829
|
+
import { cosineSimilarity } from "@warlock.js/cache";
|
|
1830
|
+
|
|
1831
|
+
cosineSimilarity([1, 0, 0], [1, 0, 0]); // 1
|
|
1832
|
+
cosineSimilarity([1, 0, 0], [0, 1, 0]); // 0
|
|
1833
|
+
cosineSimilarity([1, 2, 3], [1, 2]); // throws — dimension mismatch
|
|
1834
|
+
```
|
|
1835
|
+
|
|
1836
|
+
Powers the brute-force `cache.similar()` on the memory drivers — reach for it
|
|
1837
|
+
directly only when scoring vectors outside the cache.
|
|
1838
|
+
|
|
1839
|
+
## TTL constants — `CACHE_FOR`
|
|
1840
|
+
|
|
1841
|
+
```ts
|
|
1842
|
+
import { cache, CACHE_FOR } from "@warlock.js/cache";
|
|
1843
|
+
|
|
1844
|
+
await cache.set("report", data, CACHE_FOR.ONE_WEEK);
|
|
1845
|
+
```
|
|
1846
|
+
|
|
1847
|
+
Members: `HALF_HOUR`, `ONE_HOUR`, `HALF_DAY`, `ONE_DAY`, `ONE_WEEK`,
|
|
1848
|
+
`HALF_MONTH`, `ONE_MONTH`, `TWO_MONTHS`, `SIX_MONTHS`, `ONE_YEAR` (all seconds).
|
|
1849
|
+
For most call sites the duration string (`"1h"`, `"7d"`) reads better.
|
|
1850
|
+
|
|
1851
|
+
## See also
|
|
1852
|
+
|
|
1853
|
+
- [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — the high-level `set` options these normalize
|
|
1854
|
+
- [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — `cache.similar()`, which uses `cosineSimilarity`
|
|
1855
|
+
- [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — building a custom driver where these help
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
## use-cached-hof `@warlock.js/cache/use-cached-hof/SKILL.md`
|
|
1859
|
+
|
|
1860
|
+
---
|
|
1861
|
+
name: use-cached-hof
|
|
1862
|
+
description: 'Wrap an async function with cached(fn, options) — declare the caching strategy once, call from many sites, get a bound .invalidate(...args) helper. Triggers: `cached`, `invalidate`, `key`, `ttl`, `tags`, `driver`; "wrap a DB lookup with caching", "memoize a function and invalidate by args", "one declaration many call sites", "auto-derive cache key from args"; typical import `import { cached } from "@warlock.js/cache"`. Skip: one-shot memoization — `@warlock.js/cache/apply-cache-patterns/SKILL.md` (`cache.remember`); tag bulk drop — `@warlock.js/cache/use-cache-tags/SKILL.md`; competing libs `p-memoize`, `mem`, `lodash.memoize`.'
|
|
1863
|
+
---
|
|
1864
|
+
|
|
1865
|
+
# `cached()` — function-memoization wrapper
|
|
1866
|
+
|
|
1867
|
+
`cached()` turns any async function into a memoized version. One declaration, many call sites, with a bound `.invalidate()` helper.
|
|
1868
|
+
|
|
1869
|
+
## When to use
|
|
1870
|
+
|
|
1871
|
+
Reach for `cached()` instead of `cache.remember()` when:
|
|
1872
|
+
- You have a function you'll call from many places.
|
|
1873
|
+
- You want the caching strategy declared once, not repeated at every call site.
|
|
1874
|
+
- You want `.invalidate()` available without manually deriving keys.
|
|
1875
|
+
|
|
1876
|
+
Stick with `cache.remember()` for one-shot "get-or-compute" calls.
|
|
1877
|
+
|
|
1878
|
+
## The three shapes — `fn` always first
|
|
1879
|
+
|
|
1880
|
+
```ts
|
|
1881
|
+
import { cached } from "@warlock.js/cache";
|
|
1882
|
+
|
|
1883
|
+
// 1. Prefix shorthand — driver default TTL; key auto-derived from args
|
|
1884
|
+
cached(fn, "user");
|
|
1885
|
+
|
|
1886
|
+
// 2. Prefix + TTL
|
|
1887
|
+
cached(fn, "user", "1h");
|
|
1888
|
+
|
|
1889
|
+
// 3. Options form — custom key fn, tags, per-call driver
|
|
1890
|
+
cached(fn, {
|
|
1891
|
+
key: (id: number) => `user.${id}`,
|
|
1892
|
+
ttl: "1h",
|
|
1893
|
+
tags: ["users"],
|
|
1894
|
+
driver: "redis",
|
|
1895
|
+
});
|
|
1896
|
+
```
|
|
1897
|
+
|
|
1898
|
+
## Auto-key rules (shorthand only)
|
|
1899
|
+
|
|
1900
|
+
| Args | Key |
|
|
1901
|
+
|------|-----|
|
|
1902
|
+
| None | `prefix` |
|
|
1903
|
+
| All primitives (incl. `null` / `undefined` / `bigint`) | `prefix.` + args joined with dots |
|
|
1904
|
+
| Any object / array arg | `prefix.` + `JSON.stringify(args)` |
|
|
1905
|
+
| Unserializable (circular / `BigInt` nested in object) | throws `CacheConfigurationError` |
|
|
1906
|
+
|
|
1907
|
+
Footguns: order matters (`fn(1, 2)` and `fn(2, 1)` differ), `Date` → ISO string, `Map` / `Set` → `{}` (use the options form). When auto-key fails, use the options form with a custom `key` fn.
|
|
1908
|
+
|
|
1909
|
+
## Return shape
|
|
1910
|
+
|
|
1911
|
+
```ts
|
|
1912
|
+
type CachedFn<Args, R> = ((...args: Args) => Promise<R>) & {
|
|
1913
|
+
invalidate(...args: Args): Promise<void>;
|
|
1914
|
+
};
|
|
1915
|
+
```
|
|
1916
|
+
|
|
1917
|
+
`.refresh()` and `.peek()` are deferred to v2.1 — file demand in `backlog.md` if you need them.
|
|
1918
|
+
|
|
1919
|
+
## Recipes
|
|
1920
|
+
|
|
1921
|
+
### Cached DB lookup with write-side invalidation
|
|
1922
|
+
|
|
1923
|
+
```ts
|
|
1924
|
+
const getUser = cached((id: number) => db.users.find(id), "user", "1h");
|
|
1925
|
+
|
|
1926
|
+
// On update
|
|
1927
|
+
await db.users.update(42, patch);
|
|
1928
|
+
await getUser.invalidate(42);
|
|
1929
|
+
```
|
|
1930
|
+
|
|
1931
|
+
### Tag-based bulk invalidation across wrappers
|
|
1932
|
+
|
|
1933
|
+
```ts
|
|
1934
|
+
const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
|
|
1935
|
+
const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
|
|
1936
|
+
|
|
1937
|
+
await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
|
|
1938
|
+
```
|
|
1939
|
+
|
|
1940
|
+
See [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md).
|
|
1941
|
+
|
|
1942
|
+
### Project a subset of args into the key
|
|
1943
|
+
|
|
1944
|
+
```ts
|
|
1945
|
+
const getCategoryMeta = cached(
|
|
1946
|
+
(filters: Filters) => db.categories.meta(filters.category),
|
|
1947
|
+
{ key: (f) => `category.meta.${f.category}`, ttl: "1h" }, // ignores `sort`, `page`
|
|
1948
|
+
);
|
|
1949
|
+
```
|
|
1950
|
+
|
|
1951
|
+
## Interaction with the rest of the API
|
|
1952
|
+
|
|
1953
|
+
- Uses `cache.remember()` internally → inherits stampede protection within a single Node process.
|
|
1954
|
+
- Forwards `tags` and `driver` through the (extended) `RememberOptions` shape on `remember`.
|
|
1955
|
+
- `.invalidate()` calls `cache.remove()` — no side effects beyond the single entry.
|
|
1956
|
+
|
|
1957
|
+
## Things NOT to do
|
|
1958
|
+
|
|
1959
|
+
- Don't wrap a function that has non-JSON-serializable args with the shorthand form. Use the options form and project a stable subset into the key.
|
|
1960
|
+
- Don't rely on cross-process stampede safety. `cached` inherits `remember`'s in-process lock; cross-process needs a distributed lock via `onConflict: "create"`.
|
|
1961
|
+
- Don't include secrets in args — they'd land in cache keys. Project only the identifying fields into the key.
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
## use-swr `@warlock.js/cache/use-swr/SKILL.md`
|
|
1965
|
+
|
|
1966
|
+
---
|
|
1967
|
+
name: use-swr
|
|
1968
|
+
description: 'Stale-while-revalidate via cache.swr(key, {freshTtl, staleTtl}, fn) — returns cached instantly when fresh, returns cached + background refresh when stale, blocks only when fully expired. Triggers: `cache.swr`, `freshTtl`, `staleTtl`, `tags`, `driver`, `stale_at`; "serve stale while refreshing", "degrade when upstream is down", "never block on cache miss"; typical import `import { cache } from "@warlock.js/cache"`. Skip: block-until-fresh memoization — `@warlock.js/cache/apply-cache-patterns/SKILL.md`; observing refresh failures — `@warlock.js/cache/observe-cache/SKILL.md`; competing libs `swr` (React, client-side).'
|
|
1969
|
+
---
|
|
1970
|
+
|
|
1971
|
+
# Stale-while-revalidate — `cache.swr(key, options, fn)`
|
|
1972
|
+
|
|
1973
|
+
Returns the cached value immediately when it can; refreshes in the background when the value is getting old; only blocks when the entry is fully expired. The single biggest production-reliability win in the package — every cache miss past `freshTtl` becomes invisible to callers.
|
|
1974
|
+
|
|
1975
|
+
## When to reach for it
|
|
1976
|
+
|
|
1977
|
+
Use `cache.swr()` when **slightly-stale data is acceptable** and **the upstream is slow / occasionally fails**. That's most product-detail pages, dashboards, third-party API responses, expensive aggregations.
|
|
1978
|
+
|
|
1979
|
+
Use `cache.remember()` when freshness is non-negotiable — auth, balances, billing, anything where the user must see the latest. Remember blocks every miss; SWR doesn't. See [`@warlock.js/cache/apply-cache-patterns/SKILL.md`](@warlock.js/cache/apply-cache-patterns/SKILL.md).
|
|
1980
|
+
|
|
1981
|
+
## Three windows
|
|
1982
|
+
|
|
1983
|
+
```
|
|
1984
|
+
write freshTtl staleTtl
|
|
1985
|
+
│ │ │
|
|
1986
|
+
▼ ▼ ▼
|
|
1987
|
+
─────┬──── fresh ─────────┬──── stale ─────────┬──── expired ──→
|
|
1988
|
+
│ return cached │ return cached + │ block, refetch
|
|
1989
|
+
│ no upstream call │ bg refresh │ like a miss
|
|
1990
|
+
```
|
|
1991
|
+
|
|
1992
|
+
| Window | Behavior |
|
|
1993
|
+
|---|---|
|
|
1994
|
+
| `now < freshTtl` | Return cached. No upstream call. |
|
|
1995
|
+
| `freshTtl ≤ now < staleTtl` | Return cached immediately. Run `fn()` in background; next read sees the refreshed value. |
|
|
1996
|
+
| `now ≥ staleTtl` | Block on `fn()`. Same as `remember()`. |
|
|
1997
|
+
|
|
1998
|
+
## API
|
|
1999
|
+
|
|
2000
|
+
```ts
|
|
2001
|
+
await cache.swr(
|
|
2002
|
+
"product.42",
|
|
2003
|
+
{
|
|
2004
|
+
freshTtl: "1m", // CacheTtl — within this, no upstream call
|
|
2005
|
+
staleTtl: "1h", // CacheTtl — past this, block-and-refetch
|
|
2006
|
+
tags?: string[], // applied on first miss + every successful refresh
|
|
2007
|
+
driver?: string, // per-call driver override, like remember()
|
|
2008
|
+
},
|
|
2009
|
+
() => db.products.find(42),
|
|
2010
|
+
);
|
|
2011
|
+
```
|
|
2012
|
+
|
|
2013
|
+
`staleTtl` MUST be greater than `freshTtl` — otherwise throws.
|
|
2014
|
+
|
|
2015
|
+
## Key invariants
|
|
2016
|
+
|
|
2017
|
+
1. **Concurrent stale-window callers share one refresh.** Per-key dedupe via the driver's existing locks map — no thundering herd on background refresh.
|
|
2018
|
+
2. **Failed background refreshes preserve the stale entry.** No retry storm; the next stale-window read tries again. Failures emit `error` events for observability.
|
|
2019
|
+
3. **The caller never sees a refresh failure.** If you returned the stale value, you got your data — failures only show up via `cache.on("error", ...)`. See [`@warlock.js/cache/observe-cache/SKILL.md`](@warlock.js/cache/observe-cache/SKILL.md).
|
|
2020
|
+
4. **Tags compose.** Per-call tags + scope tags (when via `cache.namespace().swr(...)`) merge additively.
|
|
2021
|
+
5. **Scope `ttl` defaults are NOT applied to SWR.** `freshTtl` / `staleTtl` always come from the call site.
|
|
2022
|
+
|
|
2023
|
+
## Driver support
|
|
2024
|
+
|
|
2025
|
+
| Driver | Background refresh |
|
|
2026
|
+
|---|---|
|
|
2027
|
+
| memory / memoryExtended / lru / file / mock | ✅ Full |
|
|
2028
|
+
| redis | ✅ Full (sidecar key for staleAt — backwards-compatible) |
|
|
2029
|
+
| pg | ✅ Full (`stale_at TIMESTAMPTZ` column — provision via `driver.schema()`) |
|
|
2030
|
+
| null | ❌ Always-fetch (null caches nothing) |
|
|
2031
|
+
|
|
2032
|
+
## Common shapes
|
|
2033
|
+
|
|
2034
|
+
```ts
|
|
2035
|
+
// Product detail — slightly stale OK, never want to block on DB
|
|
2036
|
+
await cache.swr(`product.${id}`, { freshTtl: "1m", staleTtl: "1h" }, () =>
|
|
2037
|
+
db.products.findById(id),
|
|
2038
|
+
);
|
|
2039
|
+
|
|
2040
|
+
// Dashboard — expensive aggregation, OK to be 5min stale
|
|
2041
|
+
await cache.swr(`dashboard.${tenantId}`, { freshTtl: "5m", staleTtl: "1h" }, () =>
|
|
2042
|
+
computeKPIs(tenantId),
|
|
2043
|
+
);
|
|
2044
|
+
|
|
2045
|
+
// Third-party API — degrade gracefully when upstream is down
|
|
2046
|
+
await cache.swr("exchange.rates", { freshTtl: "10m", staleTtl: "24h" }, () =>
|
|
2047
|
+
fetchFromForexAPI(),
|
|
2048
|
+
);
|
|
2049
|
+
```
|
|
2050
|
+
|
|
2051
|
+
## Through scoped caches
|
|
2052
|
+
|
|
2053
|
+
```ts
|
|
2054
|
+
const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
|
|
2055
|
+
|
|
2056
|
+
await feed.swr(
|
|
2057
|
+
"home",
|
|
2058
|
+
{ freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
|
|
2059
|
+
() => buildHomeFeed(userId),
|
|
2060
|
+
);
|
|
2061
|
+
// stored at feed.<userId>.home, tagged [user.<userId>, computed]
|
|
2062
|
+
```
|
|
2063
|
+
|
|
2064
|
+
## Things NOT to do
|
|
2065
|
+
|
|
2066
|
+
- Don't use SWR when the user must see the latest data (auth, billing). Use `remember()` instead — block-until-fresh is the right semantic there.
|
|
2067
|
+
- Don't pick `freshTtl` to be the *same* as `staleTtl` thinking it disables the stale window — that throws. Pick a tight `freshTtl` and wider `staleTtl` that reflects how stale your product can tolerate being.
|
|
2068
|
+
- Don't ignore `error` events. A persistent stream of refresh failures means upstream is broken and the cache is masking it.
|
|
2069
|
+
- Don't reach for SWR on the null driver — it caches nothing, so SWR always blocks.
|
|
2070
|
+
|
|
2071
|
+
|