@warlock.js/cache 5.19.1 → 5.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/cjs/index.cjs +1439 -331
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/cache-manager.d.mts +4 -0
  5. package/esm/cache-manager.d.mts.map +1 -1
  6. package/esm/cache-manager.mjs +23 -5
  7. package/esm/cache-manager.mjs.map +1 -1
  8. package/esm/cached/auto-key.d.mts +14 -14
  9. package/esm/cached/auto-key.d.mts.map +1 -1
  10. package/esm/cached/auto-key.mjs +50 -34
  11. package/esm/cached/auto-key.mjs.map +1 -1
  12. package/esm/cached/cached.mjs +4 -0
  13. package/esm/cached/cached.mjs.map +1 -1
  14. package/esm/drivers/base-cache-driver.d.mts +84 -8
  15. package/esm/drivers/base-cache-driver.d.mts.map +1 -1
  16. package/esm/drivers/base-cache-driver.mjs +152 -42
  17. package/esm/drivers/base-cache-driver.mjs.map +1 -1
  18. package/esm/drivers/file-cache-driver.d.mts +15 -0
  19. package/esm/drivers/file-cache-driver.d.mts.map +1 -1
  20. package/esm/drivers/file-cache-driver.mjs +68 -18
  21. package/esm/drivers/file-cache-driver.mjs.map +1 -1
  22. package/esm/drivers/in-memory-tag-index.d.mts +44 -0
  23. package/esm/drivers/in-memory-tag-index.d.mts.map +1 -0
  24. package/esm/drivers/in-memory-tag-index.mjs +73 -0
  25. package/esm/drivers/in-memory-tag-index.mjs.map +1 -0
  26. package/esm/drivers/index.d.mts +1 -1
  27. package/esm/drivers/index.mjs +1 -1
  28. package/esm/drivers/lru-memory-cache-driver.d.mts +43 -0
  29. package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -1
  30. package/esm/drivers/lru-memory-cache-driver.mjs +96 -0
  31. package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -1
  32. package/esm/drivers/memory-cache-driver.d.mts +87 -30
  33. package/esm/drivers/memory-cache-driver.d.mts.map +1 -1
  34. package/esm/drivers/memory-cache-driver.mjs +214 -96
  35. package/esm/drivers/memory-cache-driver.mjs.map +1 -1
  36. package/esm/drivers/memory-extended-cache-driver.d.mts +9 -5
  37. package/esm/drivers/memory-extended-cache-driver.d.mts.map +1 -1
  38. package/esm/drivers/memory-extended-cache-driver.mjs +10 -23
  39. package/esm/drivers/memory-extended-cache-driver.mjs.map +1 -1
  40. package/esm/drivers/mock-cache-driver.d.mts +41 -0
  41. package/esm/drivers/mock-cache-driver.d.mts.map +1 -1
  42. package/esm/drivers/mock-cache-driver.mjs +105 -8
  43. package/esm/drivers/mock-cache-driver.mjs.map +1 -1
  44. package/esm/drivers/null-cache-driver.d.mts +8 -2
  45. package/esm/drivers/null-cache-driver.d.mts.map +1 -1
  46. package/esm/drivers/null-cache-driver.mjs +10 -2
  47. package/esm/drivers/null-cache-driver.mjs.map +1 -1
  48. package/esm/drivers/pg-cache-driver.d.mts +77 -3
  49. package/esm/drivers/pg-cache-driver.d.mts.map +1 -1
  50. package/esm/drivers/pg-cache-driver.mjs +229 -7
  51. package/esm/drivers/pg-cache-driver.mjs.map +1 -1
  52. package/esm/drivers/redis-cache-driver.d.mts +86 -1
  53. package/esm/drivers/redis-cache-driver.d.mts.map +1 -1
  54. package/esm/drivers/redis-cache-driver.mjs +252 -16
  55. package/esm/drivers/redis-cache-driver.mjs.map +1 -1
  56. package/esm/index.d.mts +2 -2
  57. package/esm/index.mjs +2 -2
  58. package/esm/list/memory-cache-list.d.mts +11 -8
  59. package/esm/list/memory-cache-list.d.mts.map +1 -1
  60. package/esm/list/memory-cache-list.mjs +39 -37
  61. package/esm/list/memory-cache-list.mjs.map +1 -1
  62. package/esm/scoped-cache.d.mts +9 -3
  63. package/esm/scoped-cache.d.mts.map +1 -1
  64. package/esm/scoped-cache.mjs +19 -7
  65. package/esm/scoped-cache.mjs.map +1 -1
  66. package/esm/tagged-cache.d.mts +20 -0
  67. package/esm/tagged-cache.d.mts.map +1 -1
  68. package/esm/tagged-cache.mjs +62 -24
  69. package/esm/tagged-cache.mjs.map +1 -1
  70. package/esm/tagged-scoped-cache.d.mts +7 -3
  71. package/esm/tagged-scoped-cache.d.mts.map +1 -1
  72. package/esm/tagged-scoped-cache.mjs +19 -7
  73. package/esm/tagged-scoped-cache.mjs.map +1 -1
  74. package/esm/types.d.mts +41 -3
  75. package/esm/types.d.mts.map +1 -1
  76. package/esm/types.mjs.map +1 -1
  77. package/esm/utils.d.mts +9 -3
  78. package/esm/utils.d.mts.map +1 -1
  79. package/esm/utils.mjs +16 -6
  80. package/esm/utils.mjs.map +1 -1
  81. package/llms-full.txt +1685 -1666
  82. package/llms.txt +18 -18
  83. package/package.json +4 -4
  84. package/skills/apply-cache-patterns/SKILL.md +98 -97
  85. package/skills/configure-pg-cache/SKILL.md +6 -5
  86. package/skills/configure-set-options/SKILL.md +102 -96
  87. package/skills/handle-cache-errors/SKILL.md +92 -92
  88. package/skills/index.md +20 -0
  89. package/skills/observe-cache/SKILL.md +103 -103
  90. package/skills/pick-cache-driver/SKILL.md +129 -123
  91. package/skills/test-cache-code/SKILL.md +219 -219
  92. package/skills/use-cache-atomic/SKILL.md +57 -65
  93. package/skills/use-cache-bulk/SKILL.md +57 -57
  94. package/skills/use-cache-list/SKILL.md +85 -85
  95. package/skills/use-cache-lock/SKILL.md +113 -104
  96. package/skills/use-cache-namespace/SKILL.md +88 -88
  97. package/skills/use-cache-similarity/SKILL.md +94 -94
  98. package/skills/use-cache-tags/SKILL.md +7 -4
  99. package/skills/use-cache-update-merge/SKILL.md +84 -84
  100. package/skills/use-cache-utils/SKILL.md +89 -89
  101. package/skills/use-cached-hof/SKILL.md +100 -99
  102. package/skills/use-swr/SKILL.md +104 -104
package/llms-full.txt CHANGED
@@ -6,103 +6,104 @@
6
6
 
7
7
  ## apply-cache-patterns `@warlock.js/cache/apply-cache-patterns/SKILL.md`
8
8
 
9
- ---
10
- name: apply-cache-patterns
11
- description: "Real-world caching patterns in @warlock.js/cache; use when you need to apply cache patterns."
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
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 only miss. `0`, `false` and `""` are cached like any other value, so `remember` doesn't recompute them. A callback returning `null` is not cached, so the callback re-runs on the next call: to cache a "not found," store a sentinel (see negative caching below).
29
+ - Stampede protection is per process, even on redis. To run something once across servers, wrap it in `cache.lock()` on `redis` or `pg`.
30
+
31
+ ## Cross-process stampede protection — distributed lock via `onConflict`
32
+
33
+ `remember`'s lock is per-process. For cross-node safety, acquire a short-lived distributed lock before doing expensive work:
34
+
35
+ ```ts
36
+ const lockKey = `lock:build-report:${reportId}`;
37
+ const acquired = await cache.set(lockKey, process.pid, {
38
+ onConflict: "create",
39
+ ttl: "2m",
40
+ });
41
+
42
+ if (!acquired.wasSet) {
43
+ // another node is already building — wait or skip
44
+ return cache.get(`report:${reportId}`);
45
+ }
46
+
47
+ try {
48
+ const report = await buildExpensiveReport(reportId);
49
+ await cache.set(`report:${reportId}`, report, "1h");
50
+ return report;
51
+ } finally {
52
+ await cache.remove(lockKey);
53
+ }
54
+ ```
55
+
56
+ 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).
57
+
58
+ ## Negative caching
59
+
60
+ Cache "not found" results with a shorter TTL to avoid hammering the origin:
61
+
62
+ ```ts
63
+ const user = await cache.remember(`user:${id}`, "5m", async () => {
64
+ const found = await db.users.find(id);
65
+ return found ?? { __miss: true };
66
+ });
67
+
68
+ if (user?.__miss) {
69
+ return null;
70
+ }
71
+ ```
72
+
73
+ Don't return raw `null` inside `remember` to "cache the miss": `null` is the miss sentinel, so the callback re-runs every time and the origin still gets hammered. The `{ __miss: true }` sentinel is what skips the next call.
74
+
75
+ ## Per-tenant caching
76
+
77
+ ```ts
78
+ // In cache config:
79
+ options: {
80
+ redis: {
81
+ url: "...",
82
+ globalPrefix: () => `tenant-${currentContext.tenantId}`,
83
+ },
84
+ }
85
+
86
+ // At the call site — no tenancy awareness needed:
87
+ await cache.set("user:1", user, "1h");
88
+ // Actual key: "tenant-42.user.1"
89
+ ```
90
+
91
+ Clear a tenant out:
92
+ ```ts
93
+ await cache.removeNamespace(""); // when globalPrefix is set, flush scopes to it
94
+ // or
95
+ await cache.tags([`tenant-${tenantId}`]).invalidate();
96
+ ```
97
+
98
+ ## See also
99
+
100
+ - [`@warlock.js/cache/use-cache-atomic/SKILL.md`](@warlock.js/cache/use-cache-atomic/SKILL.md) — `increment` / `decrement` counters
101
+ - [`@warlock.js/cache/use-cache-bulk/SKILL.md`](@warlock.js/cache/use-cache-bulk/SKILL.md) — `many` / `setMany`
102
+ - [`@warlock.js/cache/use-cache-utils/SKILL.md`](@warlock.js/cache/use-cache-utils/SKILL.md) — `CACHE_FOR` constants and TTL/key helpers
103
+ - [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md) — tag-based invalidation
104
+ - [`@warlock.js/cache/use-cache-namespace/SKILL.md`](@warlock.js/cache/use-cache-namespace/SKILL.md) — scoped handles and `removeNamespace`
105
+ - [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md) — stale-while-revalidate for slow upstreams
106
+ - [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md) — `cached()` HOF for declarative memoization
106
107
 
107
108
 
108
109
  ## cache-basics `@warlock.js/cache/cache-basics/SKILL.md`
@@ -236,7 +237,7 @@ const user = await cache.remember("user.1", "1h", async () => db.users.find(1));
236
237
 
237
238
  ---
238
239
  name: configure-pg-cache
239
- description: "pg cache driver — Postgres setup in @warlock.js/cache; use when you need to configure pg cache."
240
+ 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`.'
240
241
  ---
241
242
 
242
243
  # `pg` cache driver — Postgres setup
@@ -249,14 +250,15 @@ Persistent cache backed by your existing Postgres pool. Two modes: **KV-only** (
249
250
  2. **`pg` is an optional peer dep.** Lazy-loaded; install only if you use this driver.
250
251
  3. **No auto-migration.** Driver exposes `driver.schema()` returning a DDL string — caller runs it via their own migration tool.
251
252
  4. **Table name is regex-validated** (`[A-Za-z_][A-Za-z0-9_]*`) before DDL interpolation. No SQL injection via misconfiguration.
252
- 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.
253
+ 5. **TTL is lazy on read.** `SELECT ... WHERE expires_at IS NULL OR expires_at > now()`. Expiry is computed on the DB clock. `driver.prune(limit = 1000)` deletes expired rows in batches and returns the count; `set()` also runs it on about 1 in 200 writes. Call it from a scheduled job for unique-key workloads.
253
254
  6. **`onConflict` is race-safe at the SQL layer:**
254
255
  - `create` → `INSERT ... ON CONFLICT DO UPDATE WHERE expires_at < now() RETURNING value` (reclaims expired rows; blocks live ones).
255
256
  - `update` → `UPDATE ... WHERE expires_at IS NULL OR expires_at > now() RETURNING value`.
256
257
  - `upsert` → unconditional `INSERT ... ON CONFLICT DO UPDATE`.
257
- 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.
258
- 8. **pgvector requires `CREATE EXTENSION vector;` once on the database.** Lazy probe on first vector op throws `CacheConfigurationError` if missing; result is cached.
259
- 9. **Vectors are passed as text literals** (`'[1,2,3]'::vector`). No binary protocol dependency — works against any pg client.
258
+ 7. **Atomic ops are cross-server:** `increment` is one upsert that keeps `expires_at`, `pull` is `DELETE … RETURNING`, `update` is a compare-and-set with bounded retries (callback may run more than once). Tags use a one-statement JSON-array merge.
259
+ 8. **`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.
260
+ 9. **pgvector requires `CREATE EXTENSION vector;` once on the database.** Lazy probe on first vector op throws `CacheConfigurationError` if missing; result is cached.
261
+ 10. **Vectors are passed as text literals** (`'[1,2,3]'::vector`). No binary protocol dependency — works against any pg client.
260
262
 
261
263
  ## Configuration
262
264
 
@@ -353,305 +355,311 @@ See [`@warlock.js/cache/handle-cache-errors/SKILL.md`](@warlock.js/cache/handle-
353
355
 
354
356
  ## configure-set-options `@warlock.js/cache/configure-set-options/SKILL.md`
355
357
 
356
- ---
357
- name: configure-set-options
358
- description: "The set options object in @warlock.js/cache; use when you need to configure set options."
359
- ---
360
-
361
- # The `set` options object
362
-
363
- `cache.set(key, value, ttlOrOptions?)` — the 3rd argument accepts three shapes.
364
-
365
- ## The three shapes
366
-
367
- ```ts
368
- // 1. Number — seconds
369
- await cache.set("name", "Jane", 600);
370
-
371
- // 2. String — human-readable duration, parsed via `ms`
372
- await cache.set("name", "Jane", "10m"); // "1s", "30m", "1h", "7d", "2 weeks"
373
-
374
- // 3. Options object
375
- await cache.set("user:1", user, {
376
- ttl: "1h",
377
- expiresAt: new Date("2026-12-31"),
378
- tags: ["users"],
379
- onConflict: "create",
380
- driver: "redis",
381
- });
382
- ```
383
-
384
- ## Option keys
385
-
386
- | Key | Type | Notes |
387
- | --- | --- | --- |
388
- | `ttl` | `number \| string` | Relative expiry. Mutually exclusive with `expiresAt`. |
389
- | `expiresAt` | `number \| Date` | Absolute deadline (epoch ms or Date). Must be in the future. Mutually exclusive with `ttl`. |
390
- | `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). |
391
- | `onConflict` | `"create" \| "update" \| "upsert"` | See below. Default `"upsert"`. |
392
- | `driver` | `string` | Per-call driver override by registered name. |
393
- | `vector` | `number[]` | Embedding indexed alongside the entry for [`cache.similar()`](@warlock.js/cache/use-cache-similarity/SKILL.md). Drivers without similarity support throw `CacheUnsupportedError`. |
394
-
395
- ## `onConflict` policies
396
-
397
- Self-documenting enum; Redis maps these to `NX` / `XX` natively, others emulate.
398
-
399
- ```ts
400
- // create — set only if key is missing
401
- const result = await cache.set("lock:jobs:import", workerId, {
402
- onConflict: "create",
403
- ttl: "5m",
404
- });
405
- // result: { wasSet: true, existing: null } on acquire
406
- // { wasSet: false, existing: <prior workerId> } on conflict — someone else holds the lock
407
-
408
- if (!result.wasSet) {
409
- // another worker is already running; abort.
410
- }
411
-
412
- // update — set only if key exists (don't resurrect expired sessions)
413
- await cache.set("session:abc", session, { onConflict: "update" });
414
- ```
415
-
416
- Conditional writes (`"create"` / `"update"`) return a `CacheSetResult`; unconditional `"upsert"` returns the value or driver instance as before.
417
-
418
- ## Mutually-exclusive validations
419
-
420
- Both of these throw `CacheConfigurationError`:
421
-
422
- ```ts
423
- await cache.set("k", v, { ttl: "1h", expiresAt: Date.now() + 1000 }); // both set
424
- await cache.set("k", v, { expiresAt: Date.now() - 1000 }); // past deadline
425
- ```
426
-
427
- ## Inline tags vs `cache.tags([...]).set(...)`
428
-
429
- Both work; inline is terser when you're writing one value under known tags:
430
-
431
- ```ts
432
- // Inline
433
- await cache.set("user:1", user, { tags: ["users", "tenant-42"] });
434
-
435
- // Fluent (useful when you already have a tagged instance)
436
- const users = cache.tags(["users"]);
437
- await users.set("user:1", user);
438
- await users.invalidate(); // drops every key tagged "users"
439
- ```
440
-
441
- 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()`.
442
-
443
- ## Back-compat note
444
-
445
- Every call site using the old positional-TTL shape keeps working:
446
-
447
- ```ts
448
- await cache.set("k", v); // no TTL — driver default
449
- await cache.set("k", v, 3600); // seconds
450
- await cache.set("k", v, undefined); // same as no TTL
451
- ```
358
+ ---
359
+ name: configure-set-options
360
+ 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`.'
361
+ ---
362
+
363
+ # The `set` options object
364
+
365
+ `cache.set(key, value, ttlOrOptions?)` — the 3rd argument accepts three shapes.
366
+
367
+ ## The three shapes
368
+
369
+ ```ts
370
+ // 1. Number — seconds
371
+ await cache.set("name", "Jane", 600);
372
+
373
+ // 2. String — human-readable duration, parsed via `ms`
374
+ await cache.set("name", "Jane", "10m"); // "1s", "30m", "1h", "7d", "2 weeks"
375
+
376
+ // 3. Options object
377
+ await cache.set("user:1", user, {
378
+ ttl: "1h",
379
+ expiresAt: new Date("2026-12-31"),
380
+ tags: ["users"],
381
+ onConflict: "create",
382
+ driver: "redis",
383
+ });
384
+ ```
385
+
386
+ ## Option keys
387
+
388
+ | Key | Type | Notes |
389
+ | --- | --- | --- |
390
+ | `ttl` | `number \| string` | Relative expiry. Mutually exclusive with `expiresAt`. |
391
+ | `expiresAt` | `number \| Date` | Absolute deadline (epoch ms or Date). Must be in the future. Mutually exclusive with `ttl`. |
392
+ | `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). |
393
+ | `onConflict` | `"create" \| "update" \| "upsert"` | See below. Default `"upsert"`. |
394
+ | `driver` | `string` | Per-call driver override by registered name. |
395
+ | `vector` | `number[]` | Embedding indexed alongside the entry for [`cache.similar()`](@warlock.js/cache/use-cache-similarity/SKILL.md). Drivers without similarity support throw `CacheUnsupportedError`. |
396
+
397
+ ## TTL parsing
398
+
399
+ - Number or bare numeric string (`"3600"`) = **seconds**. Fractions and sub-second values (`0.5`, `"500ms"`) round **up** to whole seconds, never 0.
400
+ - `0` / `Infinity` = no expiry. `NaN`, negative and unparseable values throw `CacheConfigurationError`.
401
+ - `cache.lock()` refuses any TTL that never expires.
402
+
403
+ ## `onConflict` policies
404
+
405
+ Self-documenting enum; Redis maps these to `NX` / `XX` natively, others emulate.
406
+
407
+ ```ts
408
+ // create — set only if key is missing
409
+ const result = await cache.set("lock:jobs:import", workerId, {
410
+ onConflict: "create",
411
+ ttl: "5m",
412
+ });
413
+ // result: { wasSet: true, existing: null } on acquire
414
+ // { wasSet: false, existing: <prior workerId> } on conflict — someone else holds the lock
415
+
416
+ if (!result.wasSet) {
417
+ // another worker is already running; abort.
418
+ }
419
+
420
+ // update — set only if key exists (don't resurrect expired sessions)
421
+ await cache.set("session:abc", session, { onConflict: "update" });
422
+ ```
423
+
424
+ Conditional writes (`"create"` / `"update"`) return a `CacheSetResult`; unconditional `"upsert"` returns the value or driver instance as before.
425
+
426
+ ## Mutually-exclusive validations
427
+
428
+ Both of these throw `CacheConfigurationError`:
429
+
430
+ ```ts
431
+ await cache.set("k", v, { ttl: "1h", expiresAt: Date.now() + 1000 }); // both set
432
+ await cache.set("k", v, { expiresAt: Date.now() - 1000 }); // past deadline
433
+ ```
434
+
435
+ ## Inline tags vs `cache.tags([...]).set(...)`
436
+
437
+ Both work; inline is terser when you're writing one value under known tags:
438
+
439
+ ```ts
440
+ // Inline
441
+ await cache.set("user:1", user, { tags: ["users", "tenant-42"] });
442
+
443
+ // Fluent (useful when you already have a tagged instance)
444
+ const users = cache.tags(["users"]);
445
+ await users.set("user:1", user);
446
+ await users.invalidate(); // drops every key tagged "users"
447
+ ```
448
+
449
+ 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()`.
450
+
451
+ ## Back-compat note
452
+
453
+ Every call site using the old positional-TTL shape keeps working:
454
+
455
+ ```ts
456
+ await cache.set("k", v); // no TTL — driver default
457
+ await cache.set("k", v, 3600); // seconds
458
+ await cache.set("k", v, undefined); // same as no TTL
459
+ ```
452
460
 
453
461
 
454
462
  ## handle-cache-errors `@warlock.js/cache/handle-cache-errors/SKILL.md`
455
463
 
456
- ---
457
- name: handle-cache-errors
458
- description: "Error classes in @warlock.js/cache; use when you need to handle cache errors."
459
- ---
460
-
461
- # Error classes
462
-
463
- All cache errors extend `CacheError` which extends `Error`. Use `instanceof` to react selectively.
464
-
465
- ```ts
466
- import {
467
- CacheError,
468
- CacheConfigurationError,
469
- CacheConnectionError,
470
- CacheDriverNotInitializedError,
471
- CacheUnsupportedError,
472
- CacheConcurrencyError,
473
- } from "@warlock.js/cache";
474
- ```
475
-
476
- | Class | When it's thrown | How to react |
477
- | --- | --- | --- |
478
- | `CacheError` | Abstract base — don't throw directly, match against it to catch any cache error | `catch (e) { if (e instanceof CacheError) … }` |
479
- | `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. |
480
- | `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. |
481
- | `CacheDriverNotInitializedError` | Any data op called before `cache.init()` / `cache.use()` | Call `cache.init()` at app startup. Tests often forget this — add a `beforeEach`. |
482
- | `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. |
483
- | `CacheConcurrencyError` | Declared for future optimistic-concurrency exhaustion on Redis `update()` | Not thrown today. Reserved for the v2.1 `WATCH`/`MULTI` implementation. |
484
- | `CacheError` (file driver, path containment) | A key/namespace, after percent-encoding, still resolves outside the cache root — `"Cache key resolves outside the cache directory: ..."`. Last line of defense against path traversal on top of the encoding step; should not happen in normal use. | Treat as a programmer/attacker-input error — don't retry; investigate the key source. See [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) for the encoding contract. |
485
-
486
- ## Special case — `setNX` unsupported
487
-
488
- Calling `cache.setNX(...)` on a driver that doesn't implement it throws a plain `Error`, not a `CacheUnsupportedError`:
489
-
490
- ```ts
491
- // Error: "setNX is not supported by the current cache driver: memory"
492
- ```
493
-
494
- 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).
495
-
496
- ## Patterns
497
-
498
- ### Catch-all at the boundary
499
-
500
- ```ts
501
- try {
502
- await doCachedWork();
503
- } catch (error) {
504
- if (error instanceof CacheError) {
505
- logger.warn("cache unavailable, degrading", error);
506
- await doWorkWithoutCache();
507
- return;
508
- }
509
- throw error;
510
- }
511
- ```
512
-
513
- ### Selective — configuration vs runtime
514
-
515
- ```ts
516
- try {
517
- await cache.set("k", v, userSuppliedOptions);
518
- } catch (error) {
519
- if (error instanceof CacheConfigurationError) {
520
- return res.status(400).json({ error: "invalid TTL options" });
521
- }
522
- throw error;
523
- }
524
- ```
525
-
526
- ### Driver-missing fallback
527
-
528
- ```ts
529
- try {
530
- await cache.merge("user:1", { lastSeen: Date.now() });
531
- } catch (error) {
532
- if (error instanceof CacheUnsupportedError) {
533
- // File driver in dev — degrade gracefully
534
- const current = await cache.get("user:1");
535
- await cache.set("user:1", { ...current, lastSeen: Date.now() });
536
- return;
537
- }
538
- throw error;
539
- }
540
- ```
541
-
542
- ## Things the driver does NOT throw
543
-
544
- - **Missing keys** — `get()` returns `null`, never throws. Tests checking "key not in cache" should assert `resolves.toBeNull()`, not `rejects.toThrow`.
545
- - **Expired entries** — `get()` returns `null` and emits `"miss"` + `"expired"` events. No throw.
546
- - **Flush on empty** — `flush()` succeeds silently when there's nothing to flush.
547
- - **Concurrent writes clobbering each other** — last-write-wins by default. Use `update()` or `onConflict: "create"` if you need protection.
464
+ ---
465
+ name: handle-cache-errors
466
+ 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.'
467
+ ---
468
+
469
+ # Error classes
470
+
471
+ All cache errors extend `CacheError` which extends `Error`. Use `instanceof` to react selectively.
472
+
473
+ ```ts
474
+ import {
475
+ CacheError,
476
+ CacheConfigurationError,
477
+ CacheConnectionError,
478
+ CacheDriverNotInitializedError,
479
+ CacheUnsupportedError,
480
+ CacheConcurrencyError,
481
+ } from "@warlock.js/cache";
482
+ ```
483
+
484
+ | Class | When it's thrown | How to react |
485
+ | --- | --- | --- |
486
+ | `CacheError` | Abstract base — don't throw directly, match against it to catch any cache error | `catch (e) { if (e instanceof CacheError) … }` |
487
+ | `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. |
488
+ | `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. |
489
+ | `CacheDriverNotInitializedError` | Any data op called before `cache.init()` / `cache.use()` | Call `cache.init()` at app startup. Tests often forget this — add a `beforeEach`. |
490
+ | `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. |
491
+ | `CacheConcurrencyError` | Declared for future optimistic-concurrency exhaustion on Redis `update()` | Not thrown today. Reserved for the v2.1 `WATCH`/`MULTI` implementation. |
492
+ | `CacheError` (file driver, path containment) | A key/namespace, after percent-encoding, still resolves outside the cache root — `"Cache key resolves outside the cache directory: ..."`. Last line of defense against path traversal on top of the encoding step; should not happen in normal use. | Treat as a programmer/attacker-input error — don't retry; investigate the key source. See [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) for the encoding contract. |
493
+
494
+ ## Special case — `setNX` unsupported
495
+
496
+ Calling `cache.setNX(...)` on a driver that doesn't implement it throws a plain `Error`, not a `CacheUnsupportedError`:
497
+
498
+ ```ts
499
+ // Error: "setNX is not supported by the current cache driver: memory"
500
+ ```
501
+
502
+ 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).
503
+
504
+ ## Patterns
505
+
506
+ ### Catch-all at the boundary
507
+
508
+ ```ts
509
+ try {
510
+ await doCachedWork();
511
+ } catch (error) {
512
+ if (error instanceof CacheError) {
513
+ logger.warn("cache unavailable, degrading", error);
514
+ await doWorkWithoutCache();
515
+ return;
516
+ }
517
+ throw error;
518
+ }
519
+ ```
520
+
521
+ ### Selective — configuration vs runtime
522
+
523
+ ```ts
524
+ try {
525
+ await cache.set("k", v, userSuppliedOptions);
526
+ } catch (error) {
527
+ if (error instanceof CacheConfigurationError) {
528
+ return res.status(400).json({ error: "invalid TTL options" });
529
+ }
530
+ throw error;
531
+ }
532
+ ```
533
+
534
+ ### Driver-missing fallback
535
+
536
+ ```ts
537
+ try {
538
+ await cache.merge("user:1", { lastSeen: Date.now() });
539
+ } catch (error) {
540
+ if (error instanceof CacheUnsupportedError) {
541
+ // File driver in dev — degrade gracefully
542
+ const current = await cache.get("user:1");
543
+ await cache.set("user:1", { ...current, lastSeen: Date.now() });
544
+ return;
545
+ }
546
+ throw error;
547
+ }
548
+ ```
549
+
550
+ ## Things the driver does NOT throw
551
+
552
+ - **Missing keys** — `get()` returns `null`, never throws. Tests checking "key not in cache" should assert `resolves.toBeNull()`, not `rejects.toThrow`.
553
+ - **Expired entries** — `get()` returns `null` and emits `"miss"` + `"expired"` events. No throw.
554
+ - **Flush on empty** — `flush()` succeeds silently when there's nothing to flush.
555
+ - **Concurrent writes clobbering each other** — last-write-wins by default. Use `update()` or `onConflict: "create"` if you need protection.
548
556
 
549
557
 
550
558
  ## observe-cache `@warlock.js/cache/observe-cache/SKILL.md`
551
559
 
552
- ---
553
- name: observe-cache
554
- description: "Cache observability — cache.metrics() and the event bus in @warlock.js/cache; use when you need to observe cache."
555
- ---
556
-
557
- # Cache observability — `cache.metrics()` and the event bus
558
-
559
- Two layers, different jobs.
560
-
561
- ## Layer 1 — `cache.metrics()` for aggregate health
562
-
563
- Built-in collector subscribed to the manager's event bus. Returns a snapshot whenever you ask:
564
-
565
- ```ts
566
- const m = cache.metrics();
567
- // {
568
- // hits, misses, sets, removed, errors,
569
- // hitRate,
570
- // latencyMs: { p50, p95, p99, samples },
571
- // byDriver: { memory: {...}, redis: {...} },
572
- // startedAt,
573
- // }
574
- ```
575
-
576
- **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()`.
577
-
578
- **Survives `cache.use()` switches** — listens at the manager level, re-attaches to every loaded driver.
579
-
580
- **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.
581
-
582
- `cache.resetMetrics()` zeroes counters + drops the buffer + bumps `startedAt`.
583
-
584
- ## Layer 2 — Raw events for per-event reactions
585
-
586
- When you need to react to specific events (alerting, audit logs, debugging), subscribe to the event bus:
587
-
588
- ```ts
589
- cache.on("error", ({ key, error }) => {
590
- pagerDuty.trigger(`Cache error on ${key}`, error);
591
- });
592
-
593
- cache.on("miss", ({ key, driver }) => {
594
- if (key.startsWith("hot.")) auditLog.miss(key, driver);
595
- });
596
- ```
597
-
598
- Available events: `hit`, `miss`, `set`, `removed`, `flushed`, `expired`, `connected`, `disconnected`, `error`.
599
-
600
- Listeners attached via `cache.on(...)` survive driver switches the same way the metrics collector does.
601
-
602
- ## Which one to reach for
603
-
604
- | Goal | Use |
605
- |---|---|
606
- | Show hit rate / latency in a dashboard | `cache.metrics()` |
607
- | Page on cache errors | `cache.on("error", ...)` |
608
- | Periodic export to Prometheus / StatsD | `cache.metrics()` + `setInterval` + `resetMetrics()` |
609
- | Audit log of every removal | `cache.on("removed", ...)` |
610
- | Detect a specific anti-pattern (e.g. always-miss key) | `cache.on("miss", ...)` |
611
- | Debug "is the cache being hit at all?" in dev | `cache.metrics()` once at the end of a flow |
612
-
613
- Both layers can coexist — events fire whether the metrics collector is attached or not.
614
-
615
- ## Common shapes
616
-
617
- ### Periodic export, then reset
618
-
619
- ```ts
620
- setInterval(() => {
621
- const snapshot = cache.metrics();
622
- exporter.send(snapshot);
623
- cache.resetMetrics();
624
- }, 60_000);
625
- ```
626
-
627
- The snapshot now reflects the last minute of traffic, not the lifetime.
628
-
629
- ### Boundary measurement
630
-
631
- ```ts
632
- cache.resetMetrics();
633
- await runTrafficBurst();
634
- console.log(cache.metrics());
635
- ```
636
-
637
- Useful for benchmarks, soak tests, "did the cache help?" before/after comparisons.
638
-
639
- ### Per-driver isolation
640
-
641
- ```ts
642
- const m = cache.metrics();
643
- console.log(`memory hit rate: ${m.byDriver.memory?.hitRate ?? 0}`);
644
- console.log(`redis p95: ${m.byDriver.redis?.latencyMs.p95 ?? 0}ms`);
645
- ```
646
-
647
- Drivers that never fire events stay absent from `byDriver` — guard with `?.` and `?? 0`.
648
-
649
- ## Things NOT to do
650
-
651
- - Don't subscribe to events to count things and ignore the built-in collector — that's exactly what it's built to do.
652
- - 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.
653
- - 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.
654
- - Don't forget to attach early. If startup metrics matter, call `cache.metrics()` right after `cache.init()`.
560
+ ---
561
+ name: observe-cache
562
+ 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`.'
563
+ ---
564
+
565
+ # Cache observability — `cache.metrics()` and the event bus
566
+
567
+ Two layers, different jobs.
568
+
569
+ ## Layer 1 — `cache.metrics()` for aggregate health
570
+
571
+ Built-in collector subscribed to the manager's event bus. Returns a snapshot whenever you ask:
572
+
573
+ ```ts
574
+ const m = cache.metrics();
575
+ // {
576
+ // hits, misses, sets, removed, errors,
577
+ // hitRate,
578
+ // latencyMs: { p50, p95, p99, samples },
579
+ // byDriver: { memory: {...}, redis: {...} },
580
+ // startedAt,
581
+ // }
582
+ ```
583
+
584
+ **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()`.
585
+
586
+ **Survives `cache.use()` switches** — listens at the manager level, re-attaches to every loaded driver.
587
+
588
+ **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.
589
+
590
+ `cache.resetMetrics()` zeroes counters + drops the buffer + bumps `startedAt`.
591
+
592
+ ## Layer 2 — Raw events for per-event reactions
593
+
594
+ When you need to react to specific events (alerting, audit logs, debugging), subscribe to the event bus:
595
+
596
+ ```ts
597
+ cache.on("error", ({ key, error }) => {
598
+ pagerDuty.trigger(`Cache error on ${key}`, error);
599
+ });
600
+
601
+ cache.on("miss", ({ key, driver }) => {
602
+ if (key.startsWith("hot.")) auditLog.miss(key, driver);
603
+ });
604
+ ```
605
+
606
+ Available events: `hit`, `miss`, `set`, `removed`, `flushed`, `expired`, `connected`, `disconnected`, `error`.
607
+
608
+ Listeners attached via `cache.on(...)` survive driver switches the same way the metrics collector does.
609
+
610
+ ## Which one to reach for
611
+
612
+ | Goal | Use |
613
+ |---|---|
614
+ | Show hit rate / latency in a dashboard | `cache.metrics()` |
615
+ | Page on cache errors | `cache.on("error", ...)` |
616
+ | Periodic export to Prometheus / StatsD | `cache.metrics()` + `setInterval` + `resetMetrics()` |
617
+ | Audit log of every removal | `cache.on("removed", ...)` |
618
+ | Detect a specific anti-pattern (e.g. always-miss key) | `cache.on("miss", ...)` |
619
+ | Debug "is the cache being hit at all?" in dev | `cache.metrics()` once at the end of a flow |
620
+
621
+ Both layers can coexist — events fire whether the metrics collector is attached or not.
622
+
623
+ ## Common shapes
624
+
625
+ ### Periodic export, then reset
626
+
627
+ ```ts
628
+ setInterval(() => {
629
+ const snapshot = cache.metrics();
630
+ exporter.send(snapshot);
631
+ cache.resetMetrics();
632
+ }, 60_000);
633
+ ```
634
+
635
+ The snapshot now reflects the last minute of traffic, not the lifetime.
636
+
637
+ ### Boundary measurement
638
+
639
+ ```ts
640
+ cache.resetMetrics();
641
+ await runTrafficBurst();
642
+ console.log(cache.metrics());
643
+ ```
644
+
645
+ Useful for benchmarks, soak tests, "did the cache help?" before/after comparisons.
646
+
647
+ ### Per-driver isolation
648
+
649
+ ```ts
650
+ const m = cache.metrics();
651
+ console.log(`memory hit rate: ${m.byDriver.memory?.hitRate ?? 0}`);
652
+ console.log(`redis p95: ${m.byDriver.redis?.latencyMs.p95 ?? 0}ms`);
653
+ ```
654
+
655
+ Drivers that never fire events stay absent from `byDriver` — guard with `?.` and `?? 0`.
656
+
657
+ ## Things NOT to do
658
+
659
+ - Don't subscribe to events to count things and ignore the built-in collector — that's exactly what it's built to do.
660
+ - 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.
661
+ - 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.
662
+ - Don't forget to attach early. If startup metrics matter, call `cache.metrics()` right after `cache.init()`.
655
663
 
656
664
 
657
665
  ## overview `@warlock.js/cache/overview/SKILL.md`
@@ -729,1354 +737,1365 @@ Nineteen task skills. Most apps start with `cache-basics` + `pick-cache-driver`
729
737
 
730
738
  ## pick-cache-driver `@warlock.js/cache/pick-cache-driver/SKILL.md`
731
739
 
732
- ---
733
- name: pick-cache-driver
734
- description: "Cache drivers — pick the right one in @warlock.js/cache; use when you need to choose cache driver."
735
- ---
736
-
737
- # Cache drivers — pick the right one
738
-
739
- Seven production drivers + a mock driver ship in-box. Pick by durability, scope, and workload.
740
-
741
- | Driver | Process scope | Persists on restart | Good for | Avoid when |
742
- | --- | --- | --- | --- | --- |
743
- | `null` | — | — | Disabling cache in tests; feature-flagging off | You actually want caching |
744
- | `memory` | Single process | No | Hot in-process data with default TTL; smallest latency | Multi-process / multi-node |
745
- | `memoryExtended` | Single process | No | Sliding-window TTL (TTL resets on every read) | Any multi-process deploy |
746
- | `lru` | Single process | No | Bounded in-memory caches (capacity-based eviction) | Need cross-process sharing |
747
- | `file` | Single host | Yes | Build artefacts, local dev persistence across restarts | Concurrency (no locks); multi-host |
748
- | `redis` | Shared | Yes (Redis-managed) | Anything shared across processes / nodes | Single-process-only workload — overkill |
749
- | `pg` | Shared | Yes (Postgres-managed) | You already run Postgres; semantic caching / RAG via pgvector | High-throughput hot reads (Redis is faster) |
750
-
751
- ## Capability matrix
752
-
753
- | Capability | null | memory | memoryExt | lru | file | redis | pg |
754
- | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
755
- | `set` / `get` / `remove` / `flush` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
756
- | TTL (number or string) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
757
- | Sliding TTL on read | — | — | ✓ | — | — | — | — |
758
- | `removeNamespace` | noop | ✓ | ✓ | ✓ (prefix-scan) | ✓ | ✓ | ✓ (LIKE prefix) |
759
- | `onConflict: "create"` / `"update"` | noop | emulated | emulated | emulated | emulated | native `NX`/`XX` | native (INSERT ON CONFLICT) |
760
- | Native increment / decrement | — | ✓ | ✓ | ✓ | ✓ | atomic `INCRBY`/`DECRBY` | ✓ |
761
- | `update()` / `merge()` | ✓ | ✓ | ✓ | ✓ | ✗ throws | ✓ (single-process safety only today) | ✓ |
762
- | List sub-API | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (O(n) JSON blob today; native LPUSH/LRANGE in v2.1) | ✓ |
763
- | Tagged invalidation | noop | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (native GIN(tags)) |
764
- | `similar()` / `set({ vector })` | returns `[]` / noop | ✓ brute force | ✓ brute force | ✓ brute force | ✗ throws | ✗ throws (Phase 2 backlog) | ✓ (with `vector` config — pgvector) |
765
-
766
- ## Global config TTL — accepts number or string
767
-
768
- ```ts
769
- options: {
770
- redis: { url: "...", ttl: "7d" }, // string OK
771
- memory: { ttl: 3600 }, // number OK
772
- lru: { capacity: 10_000 }, // LRU has no TTL option today
773
- file: { directory: () => "/var/cache/myapp", ttl: "1h" },
774
- pg: { client: pool, ttl: "1h" }, // KV-only
775
- // pg with pgvector:
776
- // pg: { client: pool, vector: { dimensions: 1536, index: "hnsw" } },
777
- }
778
- ```
779
-
780
- ## Global prefix (multi-tenant scoping)
740
+ ---
741
+ name: pick-cache-driver
742
+ 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`.'
743
+ ---
744
+
745
+ # Cache drivers — pick the right one
746
+
747
+ Seven production drivers + a mock driver ship in-box. Pick by durability, scope, and workload.
748
+
749
+ | Driver | Process scope | Persists on restart | Good for | Avoid when |
750
+ | --- | --- | --- | --- | --- |
751
+ | `null` | — | — | Disabling cache in tests; feature-flagging off | You actually want caching |
752
+ | `memory` | Single process | No | Hot in-process data with default TTL; smallest latency | Multi-process / multi-node |
753
+ | `memoryExtended` | Single process | No | Sliding-window TTL (TTL resets on every read) | Any multi-process deploy |
754
+ | `lru` | Single process | No | Bounded in-memory caches (capacity-based eviction) | Need cross-process sharing |
755
+ | `file` | Single host | Yes | Build artefacts, local dev persistence across restarts | Concurrency (no locks); multi-host |
756
+ | `redis` | Shared | Yes (Redis-managed) | Anything shared across processes / nodes | Single-process-only workload — overkill |
757
+ | `pg` | Shared | Yes (Postgres-managed) | You already run Postgres; semantic caching / RAG via pgvector | High-throughput hot reads (Redis is faster) |
758
+
759
+ ## Capability matrix
760
+
761
+ | Capability | null | memory | memoryExt | lru | file | redis | pg |
762
+ | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
763
+ | `set` / `get` / `remove` / `flush` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
764
+ | TTL (number or string; numeric strings = seconds) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
765
+ | Sliding TTL on read | — | — | ✓ | — | — | — | — |
766
+ | `removeNamespace` | noop | ✓ | ✓ | ✓ (prefix-scan) | ✓ | ✓ | ✓ (LIKE prefix) |
767
+ | `onConflict: "create"` / `"update"` | noop | emulated | emulated | emulated | emulated | native `NX`/`XX` | native (INSERT ON CONFLICT) |
768
+ | `increment` / `decrement` / `pull` | — | one process | one process | one process | one process | **cross-server** | **cross-server** |
769
+ | `update()` / `merge()` | ✓ | one process | one process | one process | ✗ throws | **cross-server** (compare-and-set) | **cross-server** (compare-and-set) |
770
+ | List sub-API | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (O(n) JSON blob today; native LPUSH/LRANGE in v2.1) | ✓ |
771
+ | Tagged invalidation | noop | ✓ | ✓ | ✓ | ✓ (serialized index) | ✓ (native SET) | ✓ (JSON-array merge) |
772
+ | `similar()` / `set({ vector })` | returns `[]` / noop | ✓ brute force | ✓ brute force | ✓ brute force | ✗ throws | ✗ throws (Phase 2 backlog) | ✓ (with `vector` config — pgvector) |
773
+
774
+ ## Multi-server: use redis or pg
775
+
776
+ Memory, memoryExtended, lru and file caches are **per-process** (file: per host). With more than one server, use `redis` or `pg` for counters, throttles, one-time tokens (`pull`), shared `update`, `lock()` and tag invalidation; on memory each server counts and invalidates alone. `remember()`/`swr()`/`cached()` stampede protection is per process even on redis. Redis Cluster is unsupported.
777
+
778
+ Driver storage notes: `memory` stores flat keys (`get("users")` after `set("users.1")` is a miss) and `maxSize` counts entries in LRU order; redis `flush()` without a `globalPrefix` runs `FLUSHDB` (whole database), so set a prefix.
779
+
780
+ ## Global config TTL — accepts number or string
781
+
782
+ ```ts
783
+ options: {
784
+ redis: { url: "...", ttl: "7d" }, // string OK
785
+ memory: { ttl: 3600 }, // number OK
786
+ lru: { capacity: 10_000 }, // LRU has no TTL option today
787
+ file: { directory: () => "/var/cache/myapp", ttl: "1h" },
788
+ pg: { client: pool, ttl: "1h" }, // KV-only
789
+ // pg with pgvector:
790
+ // pg: { client: pool, vector: { dimensions: 1536, index: "hnsw" } },
791
+ }
792
+ ```
793
+
794
+ ## Global prefix (multi-tenant scoping)
795
+
796
+ 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:
797
+
798
+ ```ts
799
+ options: {
800
+ redis: {
801
+ url: "...",
802
+ globalPrefix: () => `tenant-${currentContext.tenantId}`,
803
+ },
804
+ }
805
+ ```
806
+
807
+ ## Key & namespace safety (file / redis)
808
+
809
+ Cache keys can carry untrusted input (e.g. a `cached()` auto-key derived from a request param), so every built-in driver treats a key as data, never as a path or a query fragment:
810
+
811
+ - **`file`** — a key/namespace maps to exactly one on-disk directory component: `%`, `/`, and `\` are percent-encoded before the path is built, so `../../etc` becomes an inert directory name instead of a traversal. The resolved path is also asserted to stay inside the configured cache root (throws `CacheError` otherwise) — a second, encoding-independent check. `removeNamespace` clears both the namespace's own directory and every dotted child (`ns`, `ns.*`), honoring `globalPrefix`.
812
+ - **`redis`** — `removeNamespace` escapes glob metacharacters (`*`, `?`, `[`, `\`) in the namespace before building its match pattern, and walks matches with a `SCAN` cursor (`scanIterator`) instead of the blocking `KEYS` command, so clearing a namespace on a large keyspace doesn't stall the event loop for other tenants.
813
+ - **Errors that echo a connection string** (Redis `connect()` failures, any driver's failed op) are logged through a `safeErrorInfo()` helper that redacts `scheme://user:pass@` credentials to `scheme://[REDACTED]@` and never prints the raw `Error` object — see [`@warlock.js/cache/handle-cache-errors/SKILL.md`](@warlock.js/cache/handle-cache-errors/SKILL.md).
814
+
815
+ ## Registering a custom driver
816
+
817
+ ```ts
818
+ import { BaseCacheDriver, cache } from "@warlock.js/cache";
819
+
820
+ class MemcachedCacheDriver extends BaseCacheDriver<MyClient, MyOptions> {
821
+ public name = "memcached";
822
+ // … implement set / get / remove / flush / removeNamespace / connect
823
+ }
824
+
825
+ cache.setCacheConfigurations({
826
+ default: "memcached",
827
+ drivers: { memcached: MemcachedCacheDriver },
828
+ options: { memcached: { host: "localhost" } },
829
+ });
830
+ ```
831
+
832
+ Extending `BaseCacheDriver` gives you free: TTL parsing, key parsing, event emission, stampede-safe `remember`, deep-clone-on-read, default `update` / `merge` / `list` implementations.
833
+
834
+ ## Runtime driver options — `cache.use(name, options)`
835
+
836
+ 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.
837
+
838
+ ```ts
839
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
840
+
841
+ cache.setCacheConfigurations({
842
+ default: "pg",
843
+ drivers: { pg: PgCacheDriver },
844
+ options: { pg: { table: "cache" } }, // static
845
+ });
846
+
847
+ await cache.use("pg", { client: pool }); // runtime — skip init() in this case
848
+ ```
849
+
850
+ Constraints:
851
+ - The driver name must be registered in `setCacheConfigurations({ drivers })` — runtime options don't bypass registration.
852
+ - 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.
853
+ - Calling without options (or with `{}`) on an already-loaded driver returns the cached instance silently.
854
+
855
+ ## Per-call driver override
856
+
857
+ When most writes go to the default driver but one call needs a different one:
858
+
859
+ ```ts
860
+ await cache.set("audit:event", event, { driver: "redis" });
861
+ ```
862
+
863
+ The manager loads (and connects) the override driver lazily on first use, then routes that single operation through it without mutating `currentDriver`.
864
+
865
+ ## See also
866
+
867
+ - [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md) — full pg setup (KV-only and pgvector mode)
868
+ - [`@warlock.js/cache/test-cache-code/SKILL.md`](@warlock.js/cache/test-cache-code/SKILL.md) — `MockCacheDriver` and `NullCacheDriver` for tests
781
869
 
782
- 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:
783
870
 
784
- ```ts
785
- options: {
786
- redis: {
787
- url: "...",
788
- globalPrefix: () => `tenant-${currentContext.tenantId}`,
789
- },
790
- }
791
- ```
871
+ ## test-cache-code `@warlock.js/cache/test-cache-code/SKILL.md`
792
872
 
793
- ## Key & namespace safety (file / redis)
873
+ ---
874
+ name: test-cache-code
875
+ 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`.'
876
+ ---
877
+
878
+ # Testing code that touches cache
879
+
880
+ Three good strategies — pick based on what you're testing.
881
+
882
+ ## Strategy 1 — `MockCacheDriver` for behavioral assertions (preferred)
883
+
884
+ 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:
885
+
886
+ - `wasCalled(operation, key?)` — was a given op invoked? Optional key matched post-`parseKey`.
887
+ - `getStored(key)` — raw stored value, bypassing TTL handling and clone protection.
888
+ - `reset()` — wipe storage, tag index, and call log in one call.
889
+ - `callLog: CacheCall[]` — ordered record of every op (operation, parsed key, raw args, timestamp).
890
+
891
+ ```ts
892
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
893
+ import { cache, MockCacheDriver } from "@warlock.js/cache";
894
+
895
+ describe("UserService.update", () => {
896
+ let driver: MockCacheDriver;
897
+
898
+ beforeEach(async () => {
899
+ cache.setCacheConfigurations({
900
+ default: "mock",
901
+ logging: false,
902
+ drivers: { mock: MockCacheDriver },
903
+ options: { mock: {} },
904
+ });
905
+ await cache.init();
906
+
907
+ driver = cache.currentDriver as MockCacheDriver;
908
+ });
909
+
910
+ afterEach(async () => {
911
+ driver.reset();
912
+ await cache.disconnect();
913
+ });
914
+
915
+ it("invalidates the user cache after update", async () => {
916
+ await new UserService().update(42, { name: "Jane" });
917
+
918
+ expect(driver.wasCalled("remove", "users.42")).toBe(true);
919
+ });
920
+
921
+ it("caches with the right TTL on read-through", async () => {
922
+ await new UserService().getProfile(1);
923
+
924
+ const setCall = driver.callLog.find((call) => call.operation === "set");
925
+ expect(setCall?.args[1]).toBe("1h");
926
+ });
927
+ });
928
+ ```
929
+
930
+ `wasCalled` normalizes object keys, so `wasCalled("set", { id: 1 })` and `wasCalled("set", "id.1")` match the same call.
931
+
932
+ ## Strategy 2 — `MemoryCacheDriver` for full-stack integration tests
933
+
934
+ 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`.
935
+
936
+ `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.
937
+
938
+ ## Strategy 3 — `NullCacheDriver` when you need cache *off*
939
+
940
+ Use `NullCacheDriver` to disable caching entirely for code paths that should still work without a cache (graceful-degradation tests):
941
+
942
+ ```ts
943
+ cache.setCacheConfigurations({
944
+ default: "null",
945
+ drivers: { null: NullCacheDriver },
946
+ options: { null: {} },
947
+ });
948
+ await cache.init();
949
+
950
+ // All cache ops no-op; get() always returns null; set() silently discards.
951
+ ```
952
+
953
+ ## Mocking Redis (for driver-level tests, not app code)
954
+
955
+ For tests that specifically exercise `RedisCacheDriver`, use `vi.mock("redis")` with an in-memory fake. Example (condensed from `redis-cache-driver.spec.ts`):
956
+
957
+ ```ts
958
+ import { vi } from "vitest";
959
+
960
+ class FakeRedisClient {
961
+ public store = new Map<string, string>();
962
+ private expires = new Map<string, number>();
963
+ public on() { return this; }
964
+ public async connect() {}
965
+ public async quit() {}
966
+ public async set(key: string, value: string, opts?: { EX?: number; NX?: boolean; XX?: boolean }) {
967
+ if (opts?.NX && this.store.has(key)) return null;
968
+ if (opts?.XX && !this.store.has(key)) return null;
969
+ this.store.set(key, value);
970
+ if (opts?.EX) this.expires.set(key, Date.now() + opts.EX * 1000);
971
+ return "OK";
972
+ }
973
+ public async get(key: string) {
974
+ const ttl = this.expires.get(key);
975
+ if (ttl && ttl < Date.now()) {
976
+ this.store.delete(key);
977
+ return null;
978
+ }
979
+ return this.store.get(key) ?? null;
980
+ }
981
+ public async del(keys: string | string[]) {
982
+ const arr = Array.isArray(keys) ? keys : [keys];
983
+ let count = 0;
984
+ for (const k of arr) if (this.store.delete(k)) count++;
985
+ return count;
986
+ }
987
+ public async keys(pattern: string) {
988
+ const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
989
+ return [...this.store.keys()].filter((k) => regex.test(k));
990
+ }
991
+ public async flushAll() { this.store.clear(); this.expires.clear(); }
992
+ public async incrBy(key: string, n: number) {
993
+ const next = Number(this.store.get(key) ?? 0) + n;
994
+ this.store.set(key, String(next));
995
+ return next;
996
+ }
997
+ public async decrBy(key: string, n: number) { return this.incrBy(key, -n); }
998
+ }
999
+
1000
+ const fakeClient = new FakeRedisClient();
1001
+ vi.mock("redis", () => ({ createClient: vi.fn(() => fakeClient) }));
1002
+ ```
1003
+
1004
+ **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:
1005
+
1006
+ ```ts
1007
+ async function importDriver() {
1008
+ const mod = await import("./redis-cache-driver");
1009
+ await new Promise((resolve) => setTimeout(resolve, 250));
1010
+ return mod.RedisCacheDriver;
1011
+ }
1012
+ ```
1013
+
1014
+ ## Silencing cache logs in tests
1015
+
1016
+ The in-package vitest config runs `silent: true`, but if you're outside the package, explicitly disable logging:
1017
+
1018
+ ```ts
1019
+ cache.setCacheConfigurations({
1020
+ default: "memory",
1021
+ logging: false, // <-- here
1022
+ drivers: { memory: MemoryCacheDriver },
1023
+ options: { memory: {} },
1024
+ });
1025
+ ```
1026
+
1027
+ Or per-driver: `driver.setLoggingState(false)`.
1028
+
1029
+ ## Spying on events
1030
+
1031
+ Every driver emits `hit`, `miss`, `set`, `removed`, `flushed`, `expired`. Attach listeners to assert cache behavior without inspecting internal state:
1032
+
1033
+ ```ts
1034
+ const hits = vi.fn();
1035
+ cache.on("hit", hits);
1036
+
1037
+ await service.getProfile("1");
1038
+ await service.getProfile("1");
1039
+
1040
+ expect(hits).toHaveBeenCalledTimes(1); // second call was a hit
1041
+ ```
1042
+
1043
+ Listeners registered via `cache.on(...)` automatically attach to any driver loaded later, so order of `on()` vs `init()` doesn't matter.
1044
+
1045
+ ## Tests for `update` / `merge` concurrency
1046
+
1047
+ The chain-serialization guarantee is worth testing when your code fans out concurrent updates:
1048
+
1049
+ ```ts
1050
+ await cache.set("counter", 0);
1051
+
1052
+ await Promise.all(
1053
+ Array.from({ length: 10 }, () =>
1054
+ cache.update<number>("counter", (c) => (c ?? 0) + 1),
1055
+ ),
1056
+ );
1057
+
1058
+ await expect(cache.get("counter")).resolves.toBe(10);
1059
+ ```
1060
+
1061
+ 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).
1062
+
1063
+ ## Testing similarity code paths
1064
+
1065
+ `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):
1066
+
1067
+ ```ts
1068
+ beforeEach(async () => {
1069
+ cache.setCacheConfigurations({
1070
+ default: "memory",
1071
+ logging: false,
1072
+ drivers: { memory: MemoryCacheDriver },
1073
+ options: { memory: {} },
1074
+ });
1075
+ await cache.init();
1076
+ });
1077
+
1078
+ it("returns the most similar doc above threshold", async () => {
1079
+ await cache.set("a", { text: "alpha" }, { vector: [1, 0, 0] });
1080
+ await cache.set("b", { text: "beta" }, { vector: [0, 1, 0] });
1081
+
1082
+ const hits = await cache.similar([1, 0, 0], { topK: 1, threshold: 0.5 });
1083
+
1084
+ expect(hits).toHaveLength(1);
1085
+ expect(hits[0].key).toBe("a");
1086
+ });
1087
+ ```
1088
+
1089
+ ## Testing the `pg` driver without a real Postgres
1090
+
1091
+ 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.
794
1092
 
795
- Cache keys can carry untrusted input (e.g. a `cached()` auto-key derived from a request param), so every built-in driver treats a key as data, never as a path or a query fragment:
796
1093
 
797
- - **`file`** — a key/namespace maps to exactly one on-disk directory component: `%`, `/`, and `\` are percent-encoded before the path is built, so `../../etc` becomes an inert directory name instead of a traversal. The resolved path is also asserted to stay inside the configured cache root (throws `CacheError` otherwise) — a second, encoding-independent check. `removeNamespace` clears both the namespace's own directory and every dotted child (`ns`, `ns.*`), honoring `globalPrefix`.
798
- - **`redis`** — `removeNamespace` escapes glob metacharacters (`*`, `?`, `[`, `\`) in the namespace before building its match pattern, and walks matches with a `SCAN` cursor (`scanIterator`) instead of the blocking `KEYS` command, so clearing a namespace on a large keyspace doesn't stall the event loop for other tenants.
799
- - **Errors that echo a connection string** (Redis `connect()` failures, any driver's failed op) are logged through a `safeErrorInfo()` helper that redacts `scheme://user:pass@` credentials to `scheme://[REDACTED]@` and never prints the raw `Error` object — see [`@warlock.js/cache/handle-cache-errors/SKILL.md`](@warlock.js/cache/handle-cache-errors/SKILL.md).
1094
+ ## use-cache-atomic `@warlock.js/cache/use-cache-atomic/SKILL.md`
800
1095
 
801
- ## Registering a custom driver
1096
+ ---
1097
+ name: use-cache-atomic
1098
+ 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`.'
1099
+ ---
1100
+
1101
+ # Atomic counters — `cache.increment` / `cache.decrement`
1102
+
1103
+ Numeric counters that go up and down without a read-then-write race in your own
1104
+ code. Both return the **new** value after the operation.
1105
+
1106
+ ```ts
1107
+ import { cache } from "@warlock.js/cache";
1108
+
1109
+ const views = await cache.increment(`post.${id}.views`); // +1 → 1, 2, 3…
1110
+ const bulk = await cache.increment(`post.${id}.views`, 10); // +10
1111
+ const left = await cache.decrement(`stock.${sku}`, 3); // -3
1112
+ ```
1113
+
1114
+ - A missing key is treated as `0`, so the first `increment` returns `by` (default `1`).
1115
+ - `decrement(key, n)` is exactly `increment(key, -n)`.
1116
+ - The stored value must be numeric — incrementing a string/object throws:
1117
+ `Error: Cannot increment non-numeric value for key: <key>`.
1118
+
1119
+ ## Atomicity is per-driver
802
1120
 
803
- ```ts
804
- import { BaseCacheDriver, cache } from "@warlock.js/cache";
1121
+ `increment`, `decrement`, `pull` and `update` are atomic, but only some drivers are atomic **across servers**:
1122
+
1123
+ | Driver | `increment` / `decrement` | `pull` | `update` / `merge` |
1124
+ |---|---|---|---|
1125
+ | `redis` | cross-server (`INCRBY`) | cross-server (`GETDEL`) | cross-server (Lua compare-and-set, retries) |
1126
+ | `pg` | cross-server (one upsert) | cross-server (`DELETE … RETURNING`) | cross-server (compare-and-set, retries) |
1127
+ | `memory` / `memoryExtended` / `lru` / `mock` | one process | one process | one process |
1128
+ | `file` | one process | one process | not supported (throws) |
1129
+
1130
+ Use `redis` or `pg` for anything several servers touch (rate limits, login throttles, one-time tokens); memory is per-process. On redis/pg the `update` callback may run more than once under contention: keep it side-effect free.
1131
+
1132
+ ## TTL is kept
1133
+
1134
+ Every driver keeps the key's **remaining TTL** across `increment`/`decrement`, so a fixed-window counter can't turn permanent. Set the TTL when you create the counter (`cache.set(key, 0, "1m")`, then `increment`). Use [`cache.update`](@warlock.js/cache/use-cache-update-merge/SKILL.md) for objects.
1135
+
1136
+ ## Common shapes
1137
+
1138
+ ```ts
1139
+ // View counter
1140
+ await cache.increment(`post.${id}.views`);
1141
+
1142
+ // Decrement stock, guard against oversell
1143
+ const remaining = await cache.decrement(`stock.${sku}`, qty);
1144
+ if (remaining < 0) {
1145
+ await cache.increment(`stock.${sku}`, qty); // roll back
1146
+ throw new Error("Out of stock");
1147
+ }
1148
+ ```
1149
+
1150
+ ## See also
1151
+
1152
+ - [`@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
1153
+ - [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md) — coordinate multi-step critical sections
1154
+ - [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — when you need cross-node atomicity
805
1155
 
806
- class MemcachedCacheDriver extends BaseCacheDriver<MyClient, MyOptions> {
807
- public name = "memcached";
808
- // … implement set / get / remove / flush / removeNamespace / connect
809
- }
810
1156
 
811
- cache.setCacheConfigurations({
812
- default: "memcached",
813
- drivers: { memcached: MemcachedCacheDriver },
814
- options: { memcached: { host: "localhost" } },
815
- });
816
- ```
1157
+ ## use-cache-bulk `@warlock.js/cache/use-cache-bulk/SKILL.md`
817
1158
 
818
- Extending `BaseCacheDriver` gives you free: TTL parsing, key parsing, event emission, stampede-safe `remember`, deep-clone-on-read, default `update` / `merge` / `list` implementations.
1159
+ ---
1160
+ name: use-cache-bulk
1161
+ 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`.'
1162
+ ---
1163
+
1164
+ # Bulk operations — `cache.many` / `cache.setMany`
1165
+
1166
+ Read or write a batch of keys in one call instead of awaiting them one at a time.
1167
+
1168
+ ## Read many — `many(keys)`
1169
+
1170
+ ```ts
1171
+ import { cache } from "@warlock.js/cache";
1172
+
1173
+ const [alice, bob, carol] = await cache.many(["user.1", "user.2", "user.3"]);
1174
+ ```
1175
+
1176
+ - Returns an array **positionally aligned** with `keys`.
1177
+ - Missing keys come back as `null` (same as `get`), so the result length always
1178
+ equals the input length — zip them back together by index.
1179
+
1180
+ ```ts
1181
+ const ids = [1, 2, 3];
1182
+ const users = await cache.many(ids.map((id) => `user.${id}`));
1183
+
1184
+ const missingIds = ids.filter((_, index) => users[index] === null);
1185
+ // fetch only the misses from the origin…
1186
+ ```
1187
+
1188
+ ## Write many — `setMany(items, ttl?)`
1189
+
1190
+ ```ts
1191
+ await cache.setMany({
1192
+ "user.1": alice,
1193
+ "user.2": bob,
1194
+ "user.3": carol,
1195
+ }, 3600); // optional TTL (seconds) applied to every entry
1196
+ ```
1197
+
1198
+ - Keys are the object keys; values are the object values.
1199
+ - The optional second arg is a single TTL (seconds) applied to **all** entries —
1200
+ there's no per-entry TTL or `tags` knob here. When you need tags or mixed TTLs,
1201
+ loop with [`cache.set`](@warlock.js/cache/configure-set-options/SKILL.md) and
1202
+ the rich options object instead.
1203
+
1204
+ ## Performance note
1205
+
1206
+ On every driver these run their underlying `get`/`set` calls **concurrently**
1207
+ (`Promise.all`) on the shared connection; on the memory family it's effectively
1208
+ instant. There's no partial-failure handling — if one write rejects, the
1209
+ returned promise rejects.
1210
+
1211
+ ## See also
1212
+
1213
+ - [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md) — single-key `get` / `set` / `remember`
1214
+ - [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — per-entry TTL, tags, conflict policy
1215
+ - [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md) — invalidate a batch by tag
819
1216
 
820
- ## Runtime driver options — `cache.use(name, options)`
821
1217
 
822
- 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.
1218
+ ## use-cache-list `@warlock.js/cache/use-cache-list/SKILL.md`
823
1219
 
824
- ```ts
825
- const pool = new Pool({ connectionString: process.env.DATABASE_URL });
1220
+ ---
1221
+ name: use-cache-list
1222
+ 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`.'
1223
+ ---
1224
+
1225
+ # Lists — the `cache.list<T>(key)` sub-API
1226
+
1227
+ 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.
1228
+
1229
+ ## Shape
1230
+
1231
+ ```ts
1232
+ const recent = cache.list<Event>("recent-events");
1233
+
1234
+ await recent.push(event); // append to tail — returns new length
1235
+ await recent.unshift(priorityEvent); // prepend to head — returns new length
1236
+ const tail = await recent.pop(); // remove + return tail
1237
+ const head = await recent.shift(); // remove + return head
1238
+ const first10 = await recent.slice(0, 10); // view — does not mutate
1239
+ const all = await recent.all();
1240
+ const count = await recent.length();
1241
+ await recent.trim(0, 99); // keep only indices 0..99 inclusive
1242
+ await recent.clear();
1243
+ ```
1244
+
1245
+ ## Type safety
1246
+
1247
+ The generic flows through every method. Pass the element type at the accessor call, not on each method:
1248
+
1249
+ ```ts
1250
+ type Event = { type: string; at: number };
1251
+ const queue = cache.list<Event>("jobs:queue");
1252
+
1253
+ await queue.push({ type: "import", at: Date.now() }); // ✓
1254
+ await queue.push("not an event" as never); // ✗ at the caller
1255
+ ```
1256
+
1257
+ ## Current performance characteristics
1258
+
1259
+ 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.
1260
+
1261
+ 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.
1262
+
1263
+ ## Concurrency warning
1264
+
1265
+ 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.
1266
+
1267
+ Single-process memory with a single writer (typical test / script usage) is fine.
1268
+
1269
+ ## Empty-list cleanup
1270
+
1271
+ 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.
1272
+
1273
+ ```ts
1274
+ await recent.push("a");
1275
+ await recent.pop();
1276
+ await cache.get("recent-events"); // null, not []
1277
+ ```
1278
+
1279
+ ## Typical recipes
1280
+
1281
+ ```ts
1282
+ // Recent-N audit log
1283
+ const audit = cache.list<AuditEntry>("audit:recent");
1284
+ await audit.unshift(entry); // newest at head
1285
+ await audit.trim(0, 999); // keep most-recent 1000
1286
+
1287
+ // Lightweight job queue (single-node)
1288
+ const queue = cache.list<Job>("jobs:pending");
1289
+ await queue.push(job);
1290
+ const next = await queue.shift(); // FIFO
1291
+
1292
+ // Stack
1293
+ const stack = cache.list<Frame>("stack");
1294
+ await stack.push(frame);
1295
+ const top = await stack.pop(); // LIFO
1296
+ ```
1297
+
1298
+ ## What lists are NOT for
1299
+
1300
+ - Unordered uniqueness — no native set today; use a plain object/Map in memory, or roll your own via `cache.get/set`.
1301
+ - Hash / field maps — same; use individual keys with a shared prefix.
1302
+ - Ordered top-N with scoring — no sorted-set analog today.
1303
+
1304
+ These are tracked as candidates for v3.
826
1305
 
827
- cache.setCacheConfigurations({
828
- default: "pg",
829
- drivers: { pg: PgCacheDriver },
830
- options: { pg: { table: "cache" } }, // static
831
- });
832
1306
 
833
- await cache.use("pg", { client: pool }); // runtime — skip init() in this case
834
- ```
1307
+ ## use-cache-lock `@warlock.js/cache/use-cache-lock/SKILL.md`
835
1308
 
836
- Constraints:
837
- - The driver name must be registered in `setCacheConfigurations({ drivers })` — runtime options don't bypass registration.
838
- - 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.
839
- - Calling without options (or with `{}`) on an already-loaded driver returns the cached instance silently.
1309
+ ---
1310
+ name: use-cache-lock
1311
+ 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`.'
1312
+ ---
1313
+
1314
+ # `cache.lock()` — distributed locks with auto-release
1315
+
1316
+ `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.
1317
+
1318
+ ## When to use
1319
+
1320
+ - A task should run on only **one server at a time** (cron jobs, imports, migrations).
1321
+ - Idempotent webhook or payment processing — dedup across retries.
1322
+ - Any time you'd otherwise write `try { … } finally { cache.remove(lockKey); }`.
1323
+
1324
+ **Not for memoization** — use [`cached()`](@warlock.js/cache/use-cached-hof/SKILL.md) or `cache.remember()`.
1325
+
1326
+ ## Shape
1327
+
1328
+ ```ts
1329
+ // Primary — positional TTL
1330
+ await cache.lock(key, ttl, fn);
1331
+
1332
+ // With options — owner for debugging, per-call driver override
1333
+ await cache.lock(key, { ttl, owner?, driver? }, fn);
1334
+ ```
1335
+
1336
+ **TTL is required, finite and positive.** `0`, `Infinity`, negative, `NaN` and unparseable TTLs throw `CacheConfigurationError` before anything is written. A lock with no expiry blocks every server once its holder crashes.
1337
+
1338
+ TTL rules: a number or a bare numeric string (`"90"`) is **seconds**; `"500ms"` and `0.5` round up to 1 s. There is no renewal or fencing, so the TTL must exceed the worst-case duration of `fn`.
1339
+
1340
+ ## Lock value and release
1341
+
1342
+ - The stored value is `<owner>#<token>` (owner defaults to `pid.<pid>`; the token is random per acquisition). Match on the `<owner>#` prefix, never the bare owner.
1343
+ - Release is a compare-and-delete (atomic on memory, redis, pg): if your TTL expired and a successor acquired the key, your release leaves their lock alone.
1344
+ - A release error is logged and never replaces `fn`'s result or error.
1345
+
1346
+ ## Return shape — discriminated union
1347
+
1348
+ ```ts
1349
+ type LockOutcome<T> =
1350
+ | { acquired: true; value: T }
1351
+ | { acquired: false };
1352
+ ```
1353
+
1354
+ Unambiguous even when `fn` returns `undefined`. Narrow with TS:
1355
+
1356
+ ```ts
1357
+ const outcome = await cache.lock("lock.x", "1m", async () => compute());
1358
+
1359
+ if (outcome.acquired) {
1360
+ console.log(outcome.value); // typed
1361
+ } else {
1362
+ console.log("someone else is running");
1363
+ }
1364
+ ```
1365
+
1366
+ ## Recipes
1367
+
1368
+ ### Cron on only one server
1369
+
1370
+ ```ts
1371
+ cron.daily("3am", () =>
1372
+ cache.lock("lock.cleanup", "30m", () => db.cleanup()),
1373
+ );
1374
+ ```
1375
+
1376
+ ### Idempotent webhook
1377
+
1378
+ ```ts
1379
+ app.post("/webhooks/stripe", async (req, res) => {
1380
+ const outcome = await cache.lock(
1381
+ `webhook.stripe.${req.body.id}`,
1382
+ "24h",
1383
+ () => processStripeEvent(req.body),
1384
+ );
1385
+
1386
+ if (!outcome.acquired) {
1387
+ return res.status(200).json({ status: "already-processed" });
1388
+ }
1389
+
1390
+ res.status(200).json({ status: "processed" });
1391
+ });
1392
+ ```
1393
+
1394
+ ### Batch job with debug-friendly owner
1395
+
1396
+ ```ts
1397
+ await cache.lock(
1398
+ `lock.report.${date}`,
1399
+ { ttl: "1h", owner: `worker.${process.env.HOSTNAME}` },
1400
+ () => generateReport(date),
1401
+ );
1402
+ ```
1403
+
1404
+ `await cache.get("lock.report.2026-04-24")` reveals which worker holds the lock.
1405
+
1406
+ ## Driver behavior
1407
+
1408
+ | Driver | Cross-process safe? |
1409
+ |--------|:-:|
1410
+ | `redis` | ✅ Native `SET … NX EX`, Lua release |
1411
+ | `pg` | ✅ `INSERT … ON CONFLICT`, one-statement release |
1412
+ | `memory` / `memoryExtended` / `lru` | ❌ In-process only (exclusive within the process) |
1413
+ | `file` | ⚠️ Single-host only (exclusive create) |
1414
+ | `null` | n/a — always "acquires" |
1415
+
1416
+ ## Gotchas
1417
+
1418
+ - **Non-re-entrant in v1.** A recursive call for the same key gets `{ acquired: false }`.
1419
+ - **Don't release inside `fn`.** `lock()` handles release in `finally`. Manual `cache.remove(lockKey)` inside `fn` would let another process jump in mid-work.
1420
+ - **TTL shorter than `fn` runtime = race.** Pick a TTL with generous margin; there is no renewal.
1421
+ - **Cross-server requires `redis` or `pg`.** Memory / LRU / file drivers don't coordinate across servers.
840
1422
 
841
- ## Per-call driver override
842
1423
 
843
- When most writes go to the default driver but one call needs a different one:
1424
+ ## use-cache-namespace `@warlock.js/cache/use-cache-namespace/SKILL.md`
844
1425
 
845
- ```ts
846
- await cache.set("audit:event", event, { driver: "redis" });
847
- ```
1426
+ ---
1427
+ name: use-cache-namespace
1428
+ 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.'
1429
+ ---
1430
+
1431
+ # Scoped caches — `cache.namespace(prefix, options?)`
1432
+
1433
+ 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.
1434
+
1435
+ ## Shape
1436
+
1437
+ ```ts
1438
+ const chat = cache.namespace(`chats.${id}`, { ttl: "30d", tags: [`user.${userId}`] });
1439
+
1440
+ await chat.set("messages.10", msg); // chats.<id>.messages.10, 30d, user.<userId>
1441
+ await chat.set("draft", d, { ttl: "1h" }); // per-call ttl wins
1442
+ await chat.tags(["unread"]).set("ping", p); // tags merge: user.<userId> + unread
1443
+ await chat.clear(); // sugar for removeNamespace
1444
+ ```
1445
+
1446
+ Scopes are pure views — same connection, same driver, no extra state. Per-call options always win over scope defaults; tags merge additively.
1447
+
1448
+ ## Nested scopes
1449
+
1450
+ ```ts
1451
+ const chat = cache.namespace(`chats.${id}`, { ttl: "30d" });
1452
+ const typing = chat.namespace("typing", { ttl: "5s" }); // overrides parent ttl
1453
+
1454
+ await typing.set("user.42", true); // chats.<id>.typing.user.42, 5s
1455
+ ```
1456
+
1457
+ Nested scopes inherit defaults and can override. Tags accumulate.
1458
+
1459
+ ## When to reach for it
1460
+
1461
+ - The prefix repeats more than 2–3 times.
1462
+ - A whole prefix shares a TTL or tag policy.
1463
+ - You want `.clear()` to read like the intent ("clear this chat") instead of `removeNamespace(...)` boilerplate.
1464
+
1465
+ Inline prefixes are still fine for one-off writes.
1466
+
1467
+ ## Plain `removeNamespace` when you already have the prefix
1468
+
1469
+ When you *do* know the prefix string and don't need a scoped handle for repeated reads/writes:
1470
+
1471
+ ```ts
1472
+ await cache.set("user:1:profile", profile);
1473
+ await cache.set("user:1:prefs", prefs);
1474
+ await cache.set("user:2:profile", otherProfile);
1475
+
1476
+ await cache.removeNamespace("user.1"); // drops both user:1 entries, keeps user:2
1477
+ ```
1478
+
1479
+ Cheaper than tags (no reverse index to maintain). Every real driver supports it — memory family and `lru` by prefix-scan, `file` by directory (including dotted sibling keys like `ns.a`), `redis` by non-blocking `SCAN` (not `KEYS`), `pg` by key/`LIKE` prefix; `null` no-ops. Namespace strings are treated as data, not glob/path input — see [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) for the file-path-containment and redis-glob-escaping details.
1480
+
1481
+ ## Multi-tenant scoping at the driver level
1482
+
1483
+ 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.
1484
+
1485
+ ```ts
1486
+ options: {
1487
+ redis: {
1488
+ url: "...",
1489
+ globalPrefix: () => `tenant-${currentContext.tenantId}`,
1490
+ },
1491
+ }
1492
+ ```
1493
+
1494
+ ## SWR + namespace
1495
+
1496
+ ```ts
1497
+ const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
1498
+
1499
+ await feed.swr(
1500
+ "home",
1501
+ { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
1502
+ () => buildHomeFeed(userId),
1503
+ );
1504
+ // stored at feed.<userId>.home, tagged [user.<userId>, computed]
1505
+ ```
1506
+
1507
+ 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).
1508
+
1509
+ ## Things NOT to do
1510
+
1511
+ - 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.
1512
+ - Don't expect `cache.namespace(prefix).clear()` to do anything on the `null` driver — `removeNamespace` no-ops there (it caches nothing).
1513
+ - Don't mix prefix separators. The convention is `.` (dot) — pick one and stick with it across scopes so nested prefixes compose predictably.
848
1514
 
849
- The manager loads (and connects) the override driver lazily on first use, then routes that single operation through it without mutating `currentDriver`.
850
1515
 
851
- ## See also
1516
+ ## use-cache-similarity `@warlock.js/cache/use-cache-similarity/SKILL.md`
852
1517
 
853
- - [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md) — full pg setup (KV-only and pgvector mode)
854
- - [`@warlock.js/cache/test-cache-code/SKILL.md`](@warlock.js/cache/test-cache-code/SKILL.md) — `MockCacheDriver` and `NullCacheDriver` for tests
1518
+ ---
1519
+ name: use-cache-similarity
1520
+ 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`.'
1521
+ ---
1522
+
1523
+ # `cache.similar()` — vector-based retrieval
1524
+
1525
+ `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.
1526
+
1527
+ ## Shape
1528
+
1529
+ ```ts
1530
+ // Index on the way in.
1531
+ await cache.set(key, value, { vector: number[], tags?, ttl? });
1532
+
1533
+ // Query.
1534
+ const hits = await cache.similar<T>(queryVec, {
1535
+ topK: number, // required
1536
+ threshold?: number, // [0, 1]; hits below are dropped
1537
+ tags?: string[], // narrow candidate pool by tag (union)
1538
+ });
1539
+ // hits: { key: string; value: T; score: number }[] // score in [0, 1]
1540
+ ```
1541
+
1542
+ ## Capability matrix
1543
+
1544
+ | Driver | `similar()` |
1545
+ |---|---|
1546
+ | `memory` / `memoryExtended` / `lru` | ✅ Brute force (O(N)) — dev only past ~10k entries |
1547
+ | `pg` *with* `options.pg.vector` | ✅ pgvector + HNSW/IVFFlat index |
1548
+ | `pg` *without* `options.pg.vector` | ❌ Throws `CacheUnsupportedError` |
1549
+ | `redis` | ❌ Throws (RediSearch on backlog) |
1550
+ | `file` | ❌ Throws |
1551
+ | `null` | Returns `[]` |
1552
+
1553
+ ## Always-true facts
1554
+
1555
+ 1. **Cache is embedding-agnostic.** Caller computes vectors. The cache stores and ranks; it doesn't call out to an embedder.
1556
+ 2. **Only entries written with `set({ vector })` show up.** A plain `set` adds the entry as KV — invisible to `similar()`.
1557
+ 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.
1558
+ 4. **Tag filter narrows the candidate pool *before* ranking** — union semantics (entry must carry at least one of the listed tags).
1559
+ 5. **Dimension mismatch throws `CacheConfigurationError`** at both `set({ vector })` and `similar()` time. Don't switch embedders without re-indexing.
1560
+ 6. **TTL + LRU eviction also drop the vector** — expired or evicted entries are invisible to `similar()`.
1561
+
1562
+ ## Recipes
1563
+
1564
+ ### Semantic cache for an LLM
1565
+
1566
+ ```ts
1567
+ const queryVec = await embed(prompt);
1568
+ const hits = await cache.similar<Answer>(queryVec, { topK: 1, threshold: 0.92 });
1569
+
1570
+ if (hits.length > 0) {
1571
+ return hits[0].value; // skip the LLM call
1572
+ }
1573
+
1574
+ const answer = await llm.complete(prompt);
1575
+ await cache.set(`q.${hash(prompt)}`, answer, {
1576
+ vector: queryVec,
1577
+ ttl: "30d",
1578
+ tags: ["llm-cache"],
1579
+ });
1580
+ return answer;
1581
+ ```
1582
+
1583
+ ### Tag-narrowed RAG
1584
+
1585
+ ```ts
1586
+ const hits = await cache.similar<Doc>(await embed(question), {
1587
+ topK: 5,
1588
+ threshold: 0.7,
1589
+ tags: ["docs", `tenant.${tenantId}`],
1590
+ });
1591
+ ```
1592
+
1593
+ ### Production swap — same code, different driver
1594
+
1595
+ ```ts
1596
+ // Dev:
1597
+ options: { memory: { ttl: "1h" } }
1598
+
1599
+ // Prod — same set/similar calls; index now lives in pgvector:
1600
+ options: { pg: { client: pool, vector: { dimensions: 1536 } } }
1601
+ ```
1602
+
1603
+ See [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md).
1604
+
1605
+ ## Things NOT to do
1606
+
1607
+ - 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.
1608
+ - Don't pass an empty array as `vector` — `cosineSimilarity` throws `CacheConfigurationError`.
1609
+ - Don't mix vector dimensions in the same driver — re-embed when models change.
1610
+ - Don't expect `similar()` to surface a missing vector (`set` without the `vector` option). Plain KV entries stay out of the similarity index.
1611
+ - Don't use `topK: 0` or negative — `pg` rejects with `CacheConfigurationError`; memory drivers return `[]` but it's a code smell.
855
1612
 
856
1613
 
857
- ## test-cache-code `@warlock.js/cache/test-cache-code/SKILL.md`
1614
+ ## use-cache-tags `@warlock.js/cache/use-cache-tags/SKILL.md`
858
1615
 
859
1616
  ---
860
- name: test-cache-code
861
- description: "Testing code that touches cache in @warlock.js/cache; use when you need to test cache code."
1617
+ name: use-cache-tags
1618
+ 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`.'
862
1619
  ---
863
1620
 
864
- # Testing code that touches cache
865
-
866
- Three good strategies — pick based on what you're testing.
867
-
868
- ## Strategy 1 — `MockCacheDriver` for behavioral assertions (preferred)
1621
+ # Tag-based invalidation
869
1622
 
870
- 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:
1623
+ 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.
871
1624
 
872
- - `wasCalled(operation, key?)` — was a given op invoked? Optional key matched post-`parseKey`.
873
- - `getStored(key)` — raw stored value, bypassing TTL handling and clone protection.
874
- - `reset()` — wipe storage, tag index, and call log in one call.
875
- - `callLog: CacheCall[]` — ordered record of every op (operation, parsed key, raw args, timestamp).
1625
+ ## Attach tags on write
876
1626
 
877
1627
  ```ts
878
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
879
- import { cache, MockCacheDriver } from "@warlock.js/cache";
1628
+ // Inline — terser when you know the tags up front
1629
+ await cache.set("user:1:profile", profile, { tags: ["users", "tenant-42"] });
1630
+ await cache.set("user:1:prefs", prefs, { tags: ["users", "tenant-42"] });
880
1631
 
881
- describe("UserService.update", () => {
882
- let driver: MockCacheDriver;
1632
+ // Fluent — useful when you already have a tagged handle
1633
+ const users = cache.tags(["users"]);
1634
+ await users.set("user:1", user);
1635
+ await users.set("user:2", otherUser);
1636
+ ```
883
1637
 
884
- beforeEach(async () => {
885
- cache.setCacheConfigurations({
886
- default: "mock",
887
- logging: false,
888
- drivers: { mock: MockCacheDriver },
889
- options: { mock: {} },
890
- });
891
- await cache.init();
1638
+ ## Invalidate
892
1639
 
893
- driver = cache.currentDriver as MockCacheDriver;
894
- });
1640
+ ```ts
1641
+ // Drop everything tagged "users"
1642
+ await cache.tags(["users"]).invalidate();
895
1643
 
896
- afterEach(async () => {
897
- driver.reset();
898
- await cache.disconnect();
899
- });
1644
+ // Multi-tag — matches either tag (union)
1645
+ await cache.tags(["tenant-42"]).invalidate();
1646
+ ```
900
1647
 
901
- it("invalidates the user cache after update", async () => {
902
- await new UserService().update(42, { name: "Jane" });
1648
+ Multi-tag is **union** semantics: an entry is invalidated if it carries **at least one** of the listed tags.
903
1649
 
904
- expect(driver.wasCalled("remove", "users.42")).toBe(true);
905
- });
1650
+ ## When to reach for tags vs namespaces
906
1651
 
907
- it("caches with the right TTL on read-through", async () => {
908
- await new UserService().getProfile(1);
1652
+ | Use case | Reach for |
1653
+ | --- | --- |
1654
+ | The keys share a known prefix | `cache.removeNamespace("prefix")` ([`use-cache-namespace`](@warlock.js/cache/use-cache-namespace/SKILL.md)) |
1655
+ | The keys are spread across prefixes, tied by entity | Tags |
1656
+ | Both apply | Tags — more flexible; cheap on most drivers |
909
1657
 
910
- const setCall = driver.callLog.find((call) => call.operation === "set");
911
- expect(setCall?.args[1]).toBe("1h");
912
- });
913
- });
914
- ```
1658
+ Namespaces are cheaper (no reverse index). Tags are more powerful (any key can carry any tag).
915
1659
 
916
- `wasCalled` normalizes object keys, so `wasCalled("set", { id: 1 })` and `wasCalled("set", "id.1")` match the same call.
1660
+ ## Inline tag semantics
917
1661
 
918
- ## Strategy 2 — `MemoryCacheDriver` for full-stack integration tests
1662
+ 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.
919
1663
 
920
- 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`.
1664
+ ## Driver behavior
921
1665
 
922
- `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.
1666
+ | Driver | Tag invalidation |
1667
+ | --- | :-: |
1668
+ | `null` | noop |
1669
+ | `memory` / `memoryExtended` / `lru` | ✓ (separate index store; eviction and expiry never touch it) |
1670
+ | `file` | ✓ (serialized index) |
1671
+ | `redis` | ✓ (native SET inside the prefix) |
1672
+ | `pg` | ✓ (JSON-array merge on the index row) |
923
1673
 
924
- ## Strategy 3 — `NullCacheDriver` when you need cache *off*
1674
+ The index uses driver primitives (`tagAdd` / `tagMembers` / `tagRemove`), so concurrent tagged writes never drop members. It stores un-prefixed keys, `invalidate()` deletes exactly the members it read, writes prune up to 20 dead members, and `remove()` / `pull()` detach the key. Tags are global across scopes. Across servers, use `redis` or `pg` so every server shares one index.
925
1675
 
926
- Use `NullCacheDriver` to disable caching entirely for code paths that should still work without a cache (graceful-degradation tests):
1676
+ ## SWR with tags
927
1677
 
928
1678
  ```ts
929
- cache.setCacheConfigurations({
930
- default: "null",
931
- drivers: { null: NullCacheDriver },
932
- options: { null: {} },
933
- });
934
- await cache.init();
935
-
936
- // All cache ops no-op; get() always returns null; set() silently discards.
1679
+ await cache.swr(
1680
+ `product.${id}`,
1681
+ { freshTtl: "1m", staleTtl: "1h", tags: ["products", `tenant.${tenantId}`] },
1682
+ () => db.products.find(id),
1683
+ );
937
1684
  ```
938
1685
 
939
- ## Mocking Redis (for driver-level tests, not app code)
1686
+ Tags re-apply on every successful refresh — see [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md).
940
1687
 
941
- For tests that specifically exercise `RedisCacheDriver`, use `vi.mock("redis")` with an in-memory fake. Example (condensed from `redis-cache-driver.spec.ts`):
1688
+ ## `cached()` HOF with tags
942
1689
 
943
1690
  ```ts
944
- import { vi } from "vitest";
945
-
946
- class FakeRedisClient {
947
- public store = new Map<string, string>();
948
- private expires = new Map<string, number>();
949
- public on() { return this; }
950
- public async connect() {}
951
- public async quit() {}
952
- public async set(key: string, value: string, opts?: { EX?: number; NX?: boolean; XX?: boolean }) {
953
- if (opts?.NX && this.store.has(key)) return null;
954
- if (opts?.XX && !this.store.has(key)) return null;
955
- this.store.set(key, value);
956
- if (opts?.EX) this.expires.set(key, Date.now() + opts.EX * 1000);
957
- return "OK";
958
- }
959
- public async get(key: string) {
960
- const ttl = this.expires.get(key);
961
- if (ttl && ttl < Date.now()) {
962
- this.store.delete(key);
963
- return null;
964
- }
965
- return this.store.get(key) ?? null;
966
- }
967
- public async del(keys: string | string[]) {
968
- const arr = Array.isArray(keys) ? keys : [keys];
969
- let count = 0;
970
- for (const k of arr) if (this.store.delete(k)) count++;
971
- return count;
972
- }
973
- public async keys(pattern: string) {
974
- const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
975
- return [...this.store.keys()].filter((k) => regex.test(k));
976
- }
977
- public async flushAll() { this.store.clear(); this.expires.clear(); }
978
- public async incrBy(key: string, n: number) {
979
- const next = Number(this.store.get(key) ?? 0) + n;
980
- this.store.set(key, String(next));
981
- return next;
982
- }
983
- public async decrBy(key: string, n: number) { return this.incrBy(key, -n); }
984
- }
985
-
986
- const fakeClient = new FakeRedisClient();
987
- vi.mock("redis", () => ({ createClient: vi.fn(() => fakeClient) }));
988
- ```
989
-
990
- **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:
1691
+ const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
1692
+ const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
991
1693
 
992
- ```ts
993
- async function importDriver() {
994
- const mod = await import("./redis-cache-driver");
995
- await new Promise((resolve) => setTimeout(resolve, 250));
996
- return mod.RedisCacheDriver;
997
- }
1694
+ await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
998
1695
  ```
999
1696
 
1000
- ## Silencing cache logs in tests
1697
+ See [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md).
1001
1698
 
1002
- The in-package vitest config runs `silent: true`, but if you're outside the package, explicitly disable logging:
1699
+ ## Things NOT to do
1003
1700
 
1004
- ```ts
1005
- cache.setCacheConfigurations({
1006
- default: "memory",
1007
- logging: false, // <-- here
1008
- drivers: { memory: MemoryCacheDriver },
1009
- options: { memory: {} },
1010
- });
1011
- ```
1701
+ - Don't tag aggressively. A reverse index per tag is cheap but not free — pick tags that actually correspond to invalidation events.
1702
+ - 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.
1703
+ - 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.
1012
1704
 
1013
- Or per-driver: `driver.setLoggingState(false)`.
1014
1705
 
1015
- ## Spying on events
1706
+ ## use-cache-update-merge `@warlock.js/cache/use-cache-update-merge/SKILL.md`
1016
1707
 
1017
- Every driver emits `hit`, `miss`, `set`, `removed`, `flushed`, `expired`. Attach listeners to assert cache behavior without inspecting internal state:
1018
-
1019
- ```ts
1020
- const hits = vi.fn();
1021
- cache.on("hit", hits);
1022
-
1023
- await service.getProfile("1");
1024
- await service.getProfile("1");
1025
-
1026
- expect(hits).toHaveBeenCalledTimes(1); // second call was a hit
1027
- ```
1028
-
1029
- Listeners registered via `cache.on(...)` automatically attach to any driver loaded later, so order of `on()` vs `init()` doesn't matter.
1030
-
1031
- ## Tests for `update` / `merge` concurrency
1032
-
1033
- The chain-serialization guarantee is worth testing when your code fans out concurrent updates:
1034
-
1035
- ```ts
1036
- await cache.set("counter", 0);
1037
-
1038
- await Promise.all(
1039
- Array.from({ length: 10 }, () =>
1040
- cache.update<number>("counter", (c) => (c ?? 0) + 1),
1041
- ),
1042
- );
1043
-
1044
- await expect(cache.get("counter")).resolves.toBe(10);
1045
- ```
1046
-
1047
- 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).
1048
-
1049
- ## Testing similarity code paths
1050
-
1051
- `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):
1052
-
1053
- ```ts
1054
- beforeEach(async () => {
1055
- cache.setCacheConfigurations({
1056
- default: "memory",
1057
- logging: false,
1058
- drivers: { memory: MemoryCacheDriver },
1059
- options: { memory: {} },
1060
- });
1061
- await cache.init();
1062
- });
1063
-
1064
- it("returns the most similar doc above threshold", async () => {
1065
- await cache.set("a", { text: "alpha" }, { vector: [1, 0, 0] });
1066
- await cache.set("b", { text: "beta" }, { vector: [0, 1, 0] });
1067
-
1068
- const hits = await cache.similar([1, 0, 0], { topK: 1, threshold: 0.5 });
1069
-
1070
- expect(hits).toHaveLength(1);
1071
- expect(hits[0].key).toBe("a");
1072
- });
1073
- ```
1074
-
1075
- ## Testing the `pg` driver without a real Postgres
1076
-
1077
- 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.
1078
-
1079
-
1080
- ## use-cache-atomic `@warlock.js/cache/use-cache-atomic/SKILL.md`
1081
-
1082
- ---
1083
- name: use-cache-atomic
1084
- description: "Atomic counters — cache.increment / cache.decrement in @warlock.js/cache; use when you need to use cache atomic."
1085
- ---
1086
-
1087
- # Atomic counters — `cache.increment` / `cache.decrement`
1088
-
1089
- Numeric counters that go up and down without a read-then-write race in your own
1090
- code. Both return the **new** value after the operation.
1091
-
1092
- ```ts
1093
- import { cache } from "@warlock.js/cache";
1094
-
1095
- const views = await cache.increment(`post.${id}.views`); // +1 → 1, 2, 3…
1096
- const bulk = await cache.increment(`post.${id}.views`, 10); // +10
1097
- const left = await cache.decrement(`stock.${sku}`, 3); // -3
1098
- ```
1099
-
1100
- - A missing key is treated as `0`, so the first `increment` returns `by` (default `1`).
1101
- - `decrement(key, n)` is exactly `increment(key, -n)`.
1102
- - The stored value must be numeric — incrementing a string/object throws:
1103
- `Error: Cannot increment non-numeric value for key: <key>`.
1104
-
1105
- ## Atomicity is per-driver
1106
-
1107
- | Driver | Guarantee |
1108
- |---|---|
1109
- | `redis` | Native `INCRBY` / `DECRBY` — atomic **across processes/nodes** |
1110
- | memory family / `file` / `pg` | Read-modify-write — atomic **within one process** only |
1111
-
1112
- For a counter that multiple instances bump concurrently (a global rate limit, a
1113
- shared tally), use the [`redis`](@warlock.js/cache/pick-cache-driver/SKILL.md)
1114
- driver. In-memory counters are fine for single-node work.
1115
-
1116
- ## TTL behavior differs too
1117
-
1118
- This is the gotcha to remember:
1119
-
1120
- - **Redis** `INCRBY` **preserves** the key's existing TTL.
1121
- - **Memory-family / pg** write the new value through `set()` with the driver's
1122
- **default** TTL — they do **not** carry over the previous entry's remaining TTL.
1123
-
1124
- So if you need a counter that expires (a fixed window), set the TTL explicitly
1125
- when you create it and don't rely on `increment` to keep a window alive on the
1126
- in-memory drivers. For a value that should keep its TTL across edits, reach for
1127
- [`cache.update`](@warlock.js/cache/use-cache-update-merge/SKILL.md), which
1128
- preserves the remaining TTL.
1129
-
1130
- ## Common shapes
1131
-
1132
- ```ts
1133
- // View counter
1134
- await cache.increment(`post.${id}.views`);
1135
-
1136
- // Decrement stock, guard against oversell
1137
- const remaining = await cache.decrement(`stock.${sku}`, qty);
1138
- if (remaining < 0) {
1139
- await cache.increment(`stock.${sku}`, qty); // roll back
1140
- throw new Error("Out of stock");
1141
- }
1142
- ```
1143
-
1144
- ## See also
1145
-
1146
- - [`@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
1147
- - [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md) — coordinate multi-step critical sections
1148
- - [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — when you need cross-node atomicity
1149
-
1150
-
1151
- ## use-cache-bulk `@warlock.js/cache/use-cache-bulk/SKILL.md`
1152
-
1153
- ---
1154
- name: use-cache-bulk
1155
- description: "Bulk operations — cache.many / cache.setMany in @warlock.js/cache; use when you need to use cache bulk."
1156
- ---
1157
-
1158
- # Bulk operations — `cache.many` / `cache.setMany`
1159
-
1160
- Read or write a batch of keys in one call instead of awaiting them one at a time.
1161
-
1162
- ## Read many — `many(keys)`
1163
-
1164
- ```ts
1165
- import { cache } from "@warlock.js/cache";
1166
-
1167
- const [alice, bob, carol] = await cache.many(["user.1", "user.2", "user.3"]);
1168
- ```
1169
-
1170
- - Returns an array **positionally aligned** with `keys`.
1171
- - Missing keys come back as `null` (same as `get`), so the result length always
1172
- equals the input length — zip them back together by index.
1173
-
1174
- ```ts
1175
- const ids = [1, 2, 3];
1176
- const users = await cache.many(ids.map((id) => `user.${id}`));
1177
-
1178
- const missingIds = ids.filter((_, index) => users[index] === null);
1179
- // fetch only the misses from the origin…
1180
- ```
1181
-
1182
- ## Write many — `setMany(items, ttl?)`
1183
-
1184
- ```ts
1185
- await cache.setMany({
1186
- "user.1": alice,
1187
- "user.2": bob,
1188
- "user.3": carol,
1189
- }, 3600); // optional TTL (seconds) applied to every entry
1190
- ```
1191
-
1192
- - Keys are the object keys; values are the object values.
1193
- - The optional second arg is a single TTL (seconds) applied to **all** entries —
1194
- there's no per-entry TTL or `tags` knob here. When you need tags or mixed TTLs,
1195
- loop with [`cache.set`](@warlock.js/cache/configure-set-options/SKILL.md) and
1196
- the rich options object instead.
1197
-
1198
- ## Performance note
1199
-
1200
- On every driver these run their underlying `get`/`set` calls **concurrently**
1201
- (`Promise.all`) on the shared connection; on the memory family it's effectively
1202
- instant. There's no partial-failure handling — if one write rejects, the
1203
- returned promise rejects.
1204
-
1205
- ## See also
1206
-
1207
- - [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md) — single-key `get` / `set` / `remember`
1208
- - [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — per-entry TTL, tags, conflict policy
1209
- - [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md) — invalidate a batch by tag
1210
-
1211
-
1212
- ## use-cache-list `@warlock.js/cache/use-cache-list/SKILL.md`
1213
-
1214
- ---
1215
- name: use-cache-list
1216
- description: "Lists — the cache.list<T>(key) sub-API in @warlock.js/cache; use when you need to use cache list."
1217
- ---
1218
-
1219
- # Lists — the `cache.list<T>(key)` sub-API
1220
-
1221
- 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.
1222
-
1223
- ## Shape
1224
-
1225
- ```ts
1226
- const recent = cache.list<Event>("recent-events");
1227
-
1228
- await recent.push(event); // append to tail — returns new length
1229
- await recent.unshift(priorityEvent); // prepend to head — returns new length
1230
- const tail = await recent.pop(); // remove + return tail
1231
- const head = await recent.shift(); // remove + return head
1232
- const first10 = await recent.slice(0, 10); // view — does not mutate
1233
- const all = await recent.all();
1234
- const count = await recent.length();
1235
- await recent.trim(0, 99); // keep only indices 0..99 inclusive
1236
- await recent.clear();
1237
- ```
1238
-
1239
- ## Type safety
1240
-
1241
- The generic flows through every method. Pass the element type at the accessor call, not on each method:
1242
-
1243
- ```ts
1244
- type Event = { type: string; at: number };
1245
- const queue = cache.list<Event>("jobs:queue");
1246
-
1247
- await queue.push({ type: "import", at: Date.now() }); // ✓
1248
- await queue.push("not an event" as never); // ✗ at the caller
1249
- ```
1250
-
1251
- ## Current performance characteristics
1252
-
1253
- 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.
1254
-
1255
- 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.
1256
-
1257
- ## Concurrency warning
1258
-
1259
- 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.
1260
-
1261
- Single-process memory with a single writer (typical test / script usage) is fine.
1262
-
1263
- ## Empty-list cleanup
1264
-
1265
- 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.
1266
-
1267
- ```ts
1268
- await recent.push("a");
1269
- await recent.pop();
1270
- await cache.get("recent-events"); // null, not []
1271
- ```
1272
-
1273
- ## Typical recipes
1274
-
1275
- ```ts
1276
- // Recent-N audit log
1277
- const audit = cache.list<AuditEntry>("audit:recent");
1278
- await audit.unshift(entry); // newest at head
1279
- await audit.trim(0, 999); // keep most-recent 1000
1280
-
1281
- // Lightweight job queue (single-node)
1282
- const queue = cache.list<Job>("jobs:pending");
1283
- await queue.push(job);
1284
- const next = await queue.shift(); // FIFO
1285
-
1286
- // Stack
1287
- const stack = cache.list<Frame>("stack");
1288
- await stack.push(frame);
1289
- const top = await stack.pop(); // LIFO
1290
- ```
1291
-
1292
- ## What lists are NOT for
1293
-
1294
- - Unordered uniqueness — no native set today; use a plain object/Map in memory, or roll your own via `cache.get/set`.
1295
- - Hash / field maps — same; use individual keys with a shared prefix.
1296
- - Ordered top-N with scoring — no sorted-set analog today.
1297
-
1298
- These are tracked as candidates for v3.
1299
-
1300
-
1301
- ## use-cache-lock `@warlock.js/cache/use-cache-lock/SKILL.md`
1302
-
1303
- ---
1304
- name: use-cache-lock
1305
- description: "cache.lock() — distributed locks with auto-release in @warlock.js/cache; use when you need to use cache lock."
1306
- ---
1307
-
1308
- # `cache.lock()` — distributed locks with auto-release
1309
-
1310
- `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.
1311
-
1312
- ## When to use
1313
-
1314
- - A task should run on only **one server at a time** (cron jobs, imports, migrations).
1315
- - Idempotent webhook or payment processing — dedup across retries.
1316
- - Any time you'd otherwise write `try { … } finally { cache.remove(lockKey); }`.
1317
-
1318
- **Not for memoization** — use [`cached()`](@warlock.js/cache/use-cached-hof/SKILL.md) or `cache.remember()`.
1319
-
1320
- ## Shape
1321
-
1322
- ```ts
1323
- // Primary — positional TTL
1324
- await cache.lock(key, ttl, fn);
1325
-
1326
- // With options — owner for debugging, per-call driver override
1327
- await cache.lock(key, { ttl, owner?, driver? }, fn);
1328
- ```
1329
-
1330
- **TTL is required.** Forgotten locks stay forever if the process crashes; the TTL is your safety net.
1331
-
1332
- ## Return shape — discriminated union
1333
-
1334
- ```ts
1335
- type LockOutcome<T> =
1336
- | { acquired: true; value: T }
1337
- | { acquired: false };
1338
- ```
1339
-
1340
- Unambiguous even when `fn` returns `undefined`. Narrow with TS:
1341
-
1342
- ```ts
1343
- const outcome = await cache.lock("lock.x", "1m", async () => compute());
1344
-
1345
- if (outcome.acquired) {
1346
- console.log(outcome.value); // typed
1347
- } else {
1348
- console.log("someone else is running");
1349
- }
1350
- ```
1351
-
1352
- ## Recipes
1353
-
1354
- ### Cron on only one server
1355
-
1356
- ```ts
1357
- cron.daily("3am", () =>
1358
- cache.lock("lock.cleanup", "30m", () => db.cleanup()),
1359
- );
1360
- ```
1361
-
1362
- ### Idempotent webhook
1363
-
1364
- ```ts
1365
- app.post("/webhooks/stripe", async (req, res) => {
1366
- const outcome = await cache.lock(
1367
- `webhook.stripe.${req.body.id}`,
1368
- "24h",
1369
- () => processStripeEvent(req.body),
1370
- );
1371
-
1372
- if (!outcome.acquired) {
1373
- return res.status(200).json({ status: "already-processed" });
1374
- }
1375
-
1376
- res.status(200).json({ status: "processed" });
1377
- });
1378
- ```
1379
-
1380
- ### Batch job with debug-friendly owner
1381
-
1382
- ```ts
1383
- await cache.lock(
1384
- `lock.report.${date}`,
1385
- { ttl: "1h", owner: `worker.${process.env.HOSTNAME}` },
1386
- () => generateReport(date),
1387
- );
1388
- ```
1389
-
1390
- `await cache.get("lock.report.2026-04-24")` reveals which worker holds the lock.
1391
-
1392
- ## Driver behavior
1393
-
1394
- | Driver | Cross-process safe? |
1395
- |--------|:-:|
1396
- | `redis` | ✅ Native `SET … NX EX` |
1397
- | `memory` / `memoryExtended` / `lru` | ❌ In-process only |
1398
- | `file` | ⚠️ Single-host only (races across hosts) |
1399
- | `null` | n/a — always "acquires" |
1400
-
1401
- ## Gotchas
1402
-
1403
- - **Non-re-entrant in v1.** A recursive call for the same key gets `{ acquired: false }`.
1404
- - **Don't release inside `fn`.** `lock()` handles release in `finally`. Manual `cache.remove(lockKey)` inside `fn` would let another process jump in mid-work.
1405
- - **TTL shorter than `fn` runtime = race.** Pick a TTL with generous margin.
1406
- - **Cross-server requires Redis.** Memory / LRU drivers don't coordinate across processes.
1407
-
1408
-
1409
- ## use-cache-namespace `@warlock.js/cache/use-cache-namespace/SKILL.md`
1410
-
1411
- ---
1412
- name: use-cache-namespace
1413
- description: "Scoped caches — cache.namespace(prefix, options?) in @warlock.js/cache; use when you need to use cache namespace."
1414
- ---
1415
-
1416
- # Scoped caches — `cache.namespace(prefix, options?)`
1417
-
1418
- 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.
1419
-
1420
- ## Shape
1421
-
1422
- ```ts
1423
- const chat = cache.namespace(`chats.${id}`, { ttl: "30d", tags: [`user.${userId}`] });
1424
-
1425
- await chat.set("messages.10", msg); // chats.<id>.messages.10, 30d, user.<userId>
1426
- await chat.set("draft", d, { ttl: "1h" }); // per-call ttl wins
1427
- await chat.tags(["unread"]).set("ping", p); // tags merge: user.<userId> + unread
1428
- await chat.clear(); // sugar for removeNamespace
1429
- ```
1430
-
1431
- Scopes are pure views — same connection, same driver, no extra state. Per-call options always win over scope defaults; tags merge additively.
1432
-
1433
- ## Nested scopes
1434
-
1435
- ```ts
1436
- const chat = cache.namespace(`chats.${id}`, { ttl: "30d" });
1437
- const typing = chat.namespace("typing", { ttl: "5s" }); // overrides parent ttl
1438
-
1439
- await typing.set("user.42", true); // chats.<id>.typing.user.42, 5s
1440
- ```
1441
-
1442
- Nested scopes inherit defaults and can override. Tags accumulate.
1443
-
1444
- ## When to reach for it
1445
-
1446
- - The prefix repeats more than 2–3 times.
1447
- - A whole prefix shares a TTL or tag policy.
1448
- - You want `.clear()` to read like the intent ("clear this chat") instead of `removeNamespace(...)` boilerplate.
1449
-
1450
- Inline prefixes are still fine for one-off writes.
1451
-
1452
- ## Plain `removeNamespace` when you already have the prefix
1453
-
1454
- When you *do* know the prefix string and don't need a scoped handle for repeated reads/writes:
1455
-
1456
- ```ts
1457
- await cache.set("user:1:profile", profile);
1458
- await cache.set("user:1:prefs", prefs);
1459
- await cache.set("user:2:profile", otherProfile);
1460
-
1461
- await cache.removeNamespace("user.1"); // drops both user:1 entries, keeps user:2
1462
- ```
1463
-
1464
- Cheaper than tags (no reverse index to maintain). Every real driver supports it — memory family and `lru` by prefix-scan, `file` by directory (including dotted sibling keys like `ns.a`), `redis` by non-blocking `SCAN` (not `KEYS`), `pg` by key/`LIKE` prefix; `null` no-ops. Namespace strings are treated as data, not glob/path input — see [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) for the file-path-containment and redis-glob-escaping details.
1465
-
1466
- ## Multi-tenant scoping at the driver level
1467
-
1468
- 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.
1469
-
1470
- ```ts
1471
- options: {
1472
- redis: {
1473
- url: "...",
1474
- globalPrefix: () => `tenant-${currentContext.tenantId}`,
1475
- },
1476
- }
1477
- ```
1478
-
1479
- ## SWR + namespace
1480
-
1481
- ```ts
1482
- const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
1483
-
1484
- await feed.swr(
1485
- "home",
1486
- { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
1487
- () => buildHomeFeed(userId),
1488
- );
1489
- // stored at feed.<userId>.home, tagged [user.<userId>, computed]
1490
- ```
1491
-
1492
- 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).
1493
-
1494
- ## Things NOT to do
1495
-
1496
- - 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.
1497
- - Don't expect `cache.namespace(prefix).clear()` to do anything on the `null` driver — `removeNamespace` no-ops there (it caches nothing).
1498
- - Don't mix prefix separators. The convention is `.` (dot) — pick one and stick with it across scopes so nested prefixes compose predictably.
1499
-
1500
-
1501
- ## use-cache-similarity `@warlock.js/cache/use-cache-similarity/SKILL.md`
1502
-
1503
- ---
1504
- name: use-cache-similarity
1505
- description: "cache.similar() — vector-based retrieval in @warlock.js/cache; use when you need to use cache similarity."
1506
- ---
1507
-
1508
- # `cache.similar()` — vector-based retrieval
1509
-
1510
- `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.
1511
-
1512
- ## Shape
1513
-
1514
- ```ts
1515
- // Index on the way in.
1516
- await cache.set(key, value, { vector: number[], tags?, ttl? });
1517
-
1518
- // Query.
1519
- const hits = await cache.similar<T>(queryVec, {
1520
- topK: number, // required
1521
- threshold?: number, // [0, 1]; hits below are dropped
1522
- tags?: string[], // narrow candidate pool by tag (union)
1523
- });
1524
- // hits: { key: string; value: T; score: number }[] // score in [0, 1]
1525
- ```
1526
-
1527
- ## Capability matrix
1528
-
1529
- | Driver | `similar()` |
1530
- |---|---|
1531
- | `memory` / `memoryExtended` / `lru` | ✅ Brute force (O(N)) — dev only past ~10k entries |
1532
- | `pg` *with* `options.pg.vector` | ✅ pgvector + HNSW/IVFFlat index |
1533
- | `pg` *without* `options.pg.vector` | ❌ Throws `CacheUnsupportedError` |
1534
- | `redis` | ❌ Throws (RediSearch on backlog) |
1535
- | `file` | ❌ Throws |
1536
- | `null` | Returns `[]` |
1537
-
1538
- ## Always-true facts
1539
-
1540
- 1. **Cache is embedding-agnostic.** Caller computes vectors. The cache stores and ranks; it doesn't call out to an embedder.
1541
- 2. **Only entries written with `set({ vector })` show up.** A plain `set` adds the entry as KV — invisible to `similar()`.
1542
- 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.
1543
- 4. **Tag filter narrows the candidate pool *before* ranking** — union semantics (entry must carry at least one of the listed tags).
1544
- 5. **Dimension mismatch throws `CacheConfigurationError`** at both `set({ vector })` and `similar()` time. Don't switch embedders without re-indexing.
1545
- 6. **TTL + LRU eviction also drop the vector** — expired or evicted entries are invisible to `similar()`.
1546
-
1547
- ## Recipes
1548
-
1549
- ### Semantic cache for an LLM
1550
-
1551
- ```ts
1552
- const queryVec = await embed(prompt);
1553
- const hits = await cache.similar<Answer>(queryVec, { topK: 1, threshold: 0.92 });
1554
-
1555
- if (hits.length > 0) {
1556
- return hits[0].value; // skip the LLM call
1557
- }
1558
-
1559
- const answer = await llm.complete(prompt);
1560
- await cache.set(`q.${hash(prompt)}`, answer, {
1561
- vector: queryVec,
1562
- ttl: "30d",
1563
- tags: ["llm-cache"],
1564
- });
1565
- return answer;
1566
- ```
1567
-
1568
- ### Tag-narrowed RAG
1569
-
1570
- ```ts
1571
- const hits = await cache.similar<Doc>(await embed(question), {
1572
- topK: 5,
1573
- threshold: 0.7,
1574
- tags: ["docs", `tenant.${tenantId}`],
1575
- });
1576
- ```
1577
-
1578
- ### Production swap — same code, different driver
1579
-
1580
- ```ts
1581
- // Dev:
1582
- options: { memory: { ttl: "1h" } }
1583
-
1584
- // Prod — same set/similar calls; index now lives in pgvector:
1585
- options: { pg: { client: pool, vector: { dimensions: 1536 } } }
1586
- ```
1587
-
1588
- See [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md).
1589
-
1590
- ## Things NOT to do
1591
-
1592
- - 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.
1593
- - Don't pass an empty array as `vector` — `cosineSimilarity` throws `CacheConfigurationError`.
1594
- - Don't mix vector dimensions in the same driver — re-embed when models change.
1595
- - Don't expect `similar()` to surface a missing vector (`set` without the `vector` option). Plain KV entries stay out of the similarity index.
1596
- - Don't use `topK: 0` or negative — `pg` rejects with `CacheConfigurationError`; memory drivers return `[]` but it's a code smell.
1597
-
1598
-
1599
- ## use-cache-tags `@warlock.js/cache/use-cache-tags/SKILL.md`
1600
-
1601
- ---
1602
- name: use-cache-tags
1603
- description: "Tag-based invalidation in @warlock.js/cache; use when you need to use cache tags."
1604
- ---
1605
-
1606
- # Tag-based invalidation
1607
-
1608
- 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.
1609
-
1610
- ## Attach tags on write
1611
-
1612
- ```ts
1613
- // Inline — terser when you know the tags up front
1614
- await cache.set("user:1:profile", profile, { tags: ["users", "tenant-42"] });
1615
- await cache.set("user:1:prefs", prefs, { tags: ["users", "tenant-42"] });
1616
-
1617
- // Fluent — useful when you already have a tagged handle
1618
- const users = cache.tags(["users"]);
1619
- await users.set("user:1", user);
1620
- await users.set("user:2", otherUser);
1621
- ```
1622
-
1623
- ## Invalidate
1624
-
1625
- ```ts
1626
- // Drop everything tagged "users"
1627
- await cache.tags(["users"]).invalidate();
1628
-
1629
- // Multi-tag — matches either tag (union)
1630
- await cache.tags(["tenant-42"]).invalidate();
1631
- ```
1632
-
1633
- Multi-tag is **union** semantics: an entry is invalidated if it carries **at least one** of the listed tags.
1634
-
1635
- ## When to reach for tags vs namespaces
1636
-
1637
- | Use case | Reach for |
1638
- | --- | --- |
1639
- | The keys share a known prefix | `cache.removeNamespace("prefix")` ([`use-cache-namespace`](@warlock.js/cache/use-cache-namespace/SKILL.md)) |
1640
- | The keys are spread across prefixes, tied by entity | Tags |
1641
- | Both apply | Tags — more flexible; cheap on most drivers |
1642
-
1643
- Namespaces are cheaper (no reverse index). Tags are more powerful (any key can carry any tag).
1644
-
1645
- ## Inline tag semantics
1646
-
1647
- 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.
1648
-
1649
- ## Driver behavior
1650
-
1651
- | Driver | Tag invalidation |
1652
- | --- | :-: |
1653
- | `null` | noop |
1654
- | `memory` / `memoryExtended` / `lru` / `file` | ✓ (reverse index in driver state) |
1655
- | `redis` | ✓ |
1656
- | `pg` | ✓ native via `GIN(tags)` index |
1657
-
1658
- ## SWR with tags
1659
-
1660
- ```ts
1661
- await cache.swr(
1662
- `product.${id}`,
1663
- { freshTtl: "1m", staleTtl: "1h", tags: ["products", `tenant.${tenantId}`] },
1664
- () => db.products.find(id),
1665
- );
1666
- ```
1667
-
1668
- Tags re-apply on every successful refresh — see [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md).
1669
-
1670
- ## `cached()` HOF with tags
1671
-
1672
- ```ts
1673
- const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
1674
- const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
1675
-
1676
- await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
1677
- ```
1678
-
1679
- See [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md).
1680
-
1681
- ## Things NOT to do
1682
-
1683
- - Don't tag aggressively. A reverse index per tag is cheap but not free — pick tags that actually correspond to invalidation events.
1684
- - 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.
1685
- - 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.
1686
-
1687
-
1688
- ## use-cache-update-merge `@warlock.js/cache/use-cache-update-merge/SKILL.md`
1689
-
1690
- ---
1691
- name: use-cache-update-merge
1692
- description: "update and merge — atomic read-modify-write in @warlock.js/cache; use when you need to use cache update merge."
1693
- ---
1694
-
1695
- # `update` and `merge` — atomic read-modify-write
1696
-
1697
- 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.
1698
-
1699
- ## `update(key, fn, options?)`
1700
-
1701
- Read the current value, pass it to `fn`, write what `fn` returns.
1702
-
1703
- ```ts
1704
- // Counter increment
1705
- await cache.update<number>("views", (current) => (current ?? 0) + 1);
1706
-
1707
- // Update nested state with defaults
1708
- await cache.update<UserState>("user:1:state", (current) => ({
1709
- ...(current ?? defaultState),
1710
- lastSeenAt: Date.now(),
1711
- }));
1712
-
1713
- // Conditional update — return null to remove
1714
- await cache.update<Session>("session:abc", (current) => {
1715
- if (!current || current.expired) {
1716
- return null; // removes the key
1717
- }
1718
- return { ...current, extendedAt: Date.now() };
1719
- });
1720
- ```
1721
-
1722
- - `fn` receives `current: T | null`. Missing keys are `null`, not an exception.
1723
- - Returning `null` **removes** the entry.
1724
- - TTL is preserved by default. To reset, pass `{ ttl: "1h" }` as the 3rd arg.
1725
-
1726
- ## `merge(key, partial, options?)`
1727
-
1728
- Shallow-merge sugar for the common "update one field" shape:
1729
-
1730
- ```ts
1731
- await cache.merge<User>("user:1", { name: "Jane" });
1732
- await cache.merge<User>("user:1", { lastSeenAt: Date.now() }, { ttl: "1h" });
1733
- ```
1734
-
1735
- - **Shallow only.** Arrays are replaced wholesale. Nested objects overwrite.
1736
- - Missing key → treats current as `{}`, creates with the partial.
1737
- - Preserves existing TTL unless the options override is passed.
1738
-
1739
- 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))`.
1740
-
1741
- ## What you can't do
1742
-
1743
- - No JSONPath / dot-path partial updates (`update(key, "profile.name", "Jane")`). Use the callback form.
1744
- - No file-driver support — both methods throw `CacheUnsupportedError` there. Use memory or redis.
1745
- - 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.
1746
-
1747
- ## Concurrent-in-process correctness
1748
-
1749
- ```ts
1750
- await cache.set("counter", 0);
1751
-
1752
- // 10 concurrent increments, all on the same key — all serialize
1753
- await Promise.all(
1754
- Array.from({ length: 10 }, () =>
1755
- cache.update<number>("counter", (current) => (current ?? 0) + 1),
1756
- ),
1757
- );
1758
-
1759
- await cache.get("counter"); // 10 — not a lost-update
1760
- ```
1761
-
1762
- The per-key lock map lives on the driver instance and is cleared after each chain link finishes. No leak.
1763
-
1764
- ## When to reach for what
1765
-
1766
- | Task | Use |
1767
- | --- | --- |
1768
- | "Add 1 to this counter" | `increment()` (Redis-atomic via `INCRBY`; in-process elsewhere) |
1769
- | "Change one field on a cached object" | `merge()` |
1770
- | "Read-decide-maybe-write, possibly remove" | `update()` |
1771
- | "Only set if missing" | `set(k, v, { onConflict: "create" })` |
1772
- | "Only set if already exists" | `set(k, v, { onConflict: "update" })` |
1773
- | "Read-then-delete atomically" | `pull()` |
1708
+ ---
1709
+ name: use-cache-update-merge
1710
+ 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`.'
1711
+ ---
1712
+
1713
+ # `update` and `merge` — atomic read-modify-write
1714
+
1715
+ 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.
1716
+
1717
+ ## `update(key, fn, options?)`
1718
+
1719
+ Read the current value, pass it to `fn`, write what `fn` returns.
1720
+
1721
+ ```ts
1722
+ // Counter increment
1723
+ await cache.update<number>("views", (current) => (current ?? 0) + 1);
1724
+
1725
+ // Update nested state with defaults
1726
+ await cache.update<UserState>("user:1:state", (current) => ({
1727
+ ...(current ?? defaultState),
1728
+ lastSeenAt: Date.now(),
1729
+ }));
1730
+
1731
+ // Conditional update — return null to remove
1732
+ await cache.update<Session>("session:abc", (current) => {
1733
+ if (!current || current.expired) {
1734
+ return null; // removes the key
1735
+ }
1736
+ return { ...current, extendedAt: Date.now() };
1737
+ });
1738
+ ```
1739
+
1740
+ - `fn` receives `current: T | null`. Missing keys are `null`, not an exception.
1741
+ - Returning `null` **removes** the entry.
1742
+ - TTL is preserved by default. To reset, pass `{ ttl: "1h" }` as the 3rd arg.
1743
+
1744
+ ## `merge(key, partial, options?)`
1745
+
1746
+ Shallow-merge sugar for the common "update one field" shape:
1747
+
1748
+ ```ts
1749
+ await cache.merge<User>("user:1", { name: "Jane" });
1750
+ await cache.merge<User>("user:1", { lastSeenAt: Date.now() }, { ttl: "1h" });
1751
+ ```
1752
+
1753
+ - **Shallow only.** Arrays are replaced wholesale. Nested objects overwrite.
1754
+ - Missing key → treats current as `{}`, creates with the partial.
1755
+ - Preserves existing TTL unless the options override is passed.
1756
+
1757
+ 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))`.
1758
+
1759
+ ## What you can't do
1760
+
1761
+ - No JSONPath / dot-path partial updates (`update(key, "profile.name", "Jane")`). Use the callback form.
1762
+ - No file-driver support — both methods throw `CacheUnsupportedError` there. Use memory or redis.
1763
+ - 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.
1764
+
1765
+ ## Concurrent-in-process correctness
1766
+
1767
+ ```ts
1768
+ await cache.set("counter", 0);
1769
+
1770
+ // 10 concurrent increments, all on the same key — all serialize
1771
+ await Promise.all(
1772
+ Array.from({ length: 10 }, () =>
1773
+ cache.update<number>("counter", (current) => (current ?? 0) + 1),
1774
+ ),
1775
+ );
1776
+
1777
+ await cache.get("counter"); // 10 — not a lost-update
1778
+ ```
1779
+
1780
+ The per-key lock map lives on the driver instance and is cleared after each chain link finishes. No leak.
1781
+
1782
+ ## When to reach for what
1783
+
1784
+ | Task | Use |
1785
+ | --- | --- |
1786
+ | "Add 1 to this counter" | `increment()` (Redis-atomic via `INCRBY`; in-process elsewhere) |
1787
+ | "Change one field on a cached object" | `merge()` |
1788
+ | "Read-decide-maybe-write, possibly remove" | `update()` |
1789
+ | "Only set if missing" | `set(k, v, { onConflict: "create" })` |
1790
+ | "Only set if already exists" | `set(k, v, { onConflict: "update" })` |
1791
+ | "Read-then-delete atomically" | `pull()` |
1774
1792
 
1775
1793
 
1776
1794
  ## use-cache-utils `@warlock.js/cache/use-cache-utils/SKILL.md`
1777
1795
 
1778
- ---
1779
- name: use-cache-utils
1780
- description: "Cache utilities in @warlock.js/cache; use when you need to use cache utils."
1781
- ---
1782
-
1783
- # Cache utilities
1784
-
1785
- The helpers the drivers are built from, all re-exported from
1786
- `@warlock.js/cache`. You rarely need them at the call site — `cache.set("k", v,
1787
- "1h")` parses the duration for you. They earn their keep when you write a custom
1788
- driver or do cache-adjacent work outside the manager.
1789
-
1790
- ## TTL helpers
1791
-
1792
- ```ts
1793
- import { parseTtl, expiresAtToTtl, resolveTtl } from "@warlock.js/cache";
1794
-
1795
- parseTtl(3600); // 3600
1796
- parseTtl("1h"); // 3600 (duration string via `ms`)
1797
- parseTtl(Infinity); // Infinity (no expiry)
1798
- parseTtl(-5); // throws CacheConfigurationError
1799
-
1800
- expiresAtToTtl(new Date(Date.now() + 60_000)); // ~60 (absolute → relative seconds)
1801
- expiresAtToTtl(Date.now() - 1000); // throws — deadline in the past
1802
-
1803
- // caller ttl > expiresAt > fallback; ttl+expiresAt together throws
1804
- resolveTtl("1h", undefined, Infinity); // 3600
1805
- resolveTtl(undefined, undefined, 1800); // 1800 (fallback)
1806
- ```
1807
-
1808
- ## Option normalizers
1809
-
1810
- Coerce the polymorphic 2nd/3rd argument of `set`/`remember` into a uniform shape
1811
- — what `BaseCacheDriver` uses internally:
1812
-
1813
- ```ts
1814
- import { normalizeToOptions, normalizeToRememberOptions } from "@warlock.js/cache";
1815
-
1816
- normalizeToOptions(60); // { ttl: 60 }
1817
- normalizeToOptions("1h"); // { ttl: "1h" }
1818
- normalizeToOptions({ tags: ["x"] }); // returned as-is
1819
- normalizeToRememberOptions("1h"); // { ttl: "1h" } (no expiresAt/onConflict)
1820
- ```
1821
-
1822
- ## Key + tag helpers
1823
-
1824
- ```ts
1825
- import { parseCacheKey, mergeTagSets, injectTags } from "@warlock.js/cache";
1826
-
1827
- parseCacheKey("users:1"); // "users.1"
1828
- parseCacheKey({ page: 1, q: "John" }); // "page.1.q.John"
1829
- parseCacheKey("user:1", { globalPrefix: "app" }); // "app.user.1"
1830
-
1831
- mergeTagSets(["a", "b"], ["b", "c"]); // ["a","b","c"] (deduped union)
1832
- mergeTagSets(undefined, undefined); // undefined
1833
-
1834
- injectTags({ ttl: "1h" }, ["unread"]); // { ttl: "1h", tags: ["unread"] } (pure, no mutation)
1835
- ```
1836
-
1837
- ## Vector scoring
1838
-
1839
- ```ts
1840
- import { cosineSimilarity } from "@warlock.js/cache";
1841
-
1842
- cosineSimilarity([1, 0, 0], [1, 0, 0]); // 1
1843
- cosineSimilarity([1, 0, 0], [0, 1, 0]); // 0
1844
- cosineSimilarity([1, 2, 3], [1, 2]); // throws — dimension mismatch
1845
- ```
1846
-
1847
- Powers the brute-force `cache.similar()` on the memory drivers — reach for it
1848
- directly only when scoring vectors outside the cache.
1849
-
1850
- ## TTL constants — `CACHE_FOR`
1851
-
1852
- ```ts
1853
- import { cache, CACHE_FOR } from "@warlock.js/cache";
1854
-
1855
- await cache.set("report", data, CACHE_FOR.ONE_WEEK);
1856
- ```
1857
-
1858
- Members: `HALF_HOUR`, `ONE_HOUR`, `HALF_DAY`, `ONE_DAY`, `ONE_WEEK`,
1859
- `HALF_MONTH`, `ONE_MONTH`, `TWO_MONTHS`, `SIX_MONTHS`, `ONE_YEAR` (all seconds).
1860
- For most call sites the duration string (`"1h"`, `"7d"`) reads better.
1861
-
1862
- ## See also
1863
-
1864
- - [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — the high-level `set` options these normalize
1865
- - [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — `cache.similar()`, which uses `cosineSimilarity`
1866
- - [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — building a custom driver where these help
1796
+ ---
1797
+ name: use-cache-utils
1798
+ 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`.'
1799
+ ---
1800
+
1801
+ # Cache utilities
1802
+
1803
+ The helpers the drivers are built from, all re-exported from
1804
+ `@warlock.js/cache`. You rarely need them at the call site — `cache.set("k", v,
1805
+ "1h")` parses the duration for you. They earn their keep when you write a custom
1806
+ driver or do cache-adjacent work outside the manager.
1807
+
1808
+ ## TTL helpers
1809
+
1810
+ ```ts
1811
+ import { parseTtl, expiresAtToTtl, resolveTtl } from "@warlock.js/cache";
1812
+
1813
+ parseTtl(3600); // 3600
1814
+ parseTtl("1h"); // 3600 (duration string via `ms`)
1815
+ parseTtl(Infinity); // Infinity (no expiry)
1816
+ parseTtl(-5); // throws CacheConfigurationError
1817
+
1818
+ expiresAtToTtl(new Date(Date.now() + 60_000)); // ~60 (absolute → relative seconds)
1819
+ expiresAtToTtl(Date.now() - 1000); // throws — deadline in the past
1820
+
1821
+ // caller ttl > expiresAt > fallback; ttl+expiresAt together throws
1822
+ resolveTtl("1h", undefined, Infinity); // 3600
1823
+ resolveTtl(undefined, undefined, 1800); // 1800 (fallback)
1824
+ ```
1825
+
1826
+ ## Option normalizers
1827
+
1828
+ Coerce the polymorphic 2nd/3rd argument of `set`/`remember` into a uniform shape
1829
+ — what `BaseCacheDriver` uses internally:
1830
+
1831
+ ```ts
1832
+ import { normalizeToOptions, normalizeToRememberOptions } from "@warlock.js/cache";
1833
+
1834
+ normalizeToOptions(60); // { ttl: 60 }
1835
+ normalizeToOptions("1h"); // { ttl: "1h" }
1836
+ normalizeToOptions({ tags: ["x"] }); // returned as-is
1837
+ normalizeToRememberOptions("1h"); // { ttl: "1h" } (no expiresAt/onConflict)
1838
+ ```
1839
+
1840
+ ## Key + tag helpers
1841
+
1842
+ ```ts
1843
+ import { parseCacheKey, mergeTagSets, injectTags } from "@warlock.js/cache";
1844
+
1845
+ parseCacheKey("users:1"); // "users.1"
1846
+ parseCacheKey({ page: 1, q: "John" }); // "page.1.q.John"
1847
+ parseCacheKey("user:1", { globalPrefix: "app" }); // "app.user.1"
1848
+
1849
+ mergeTagSets(["a", "b"], ["b", "c"]); // ["a","b","c"] (deduped union)
1850
+ mergeTagSets(undefined, undefined); // undefined
1851
+
1852
+ injectTags({ ttl: "1h" }, ["unread"]); // { ttl: "1h", tags: ["unread"] } (pure, no mutation)
1853
+ ```
1854
+
1855
+ ## Vector scoring
1856
+
1857
+ ```ts
1858
+ import { cosineSimilarity } from "@warlock.js/cache";
1859
+
1860
+ cosineSimilarity([1, 0, 0], [1, 0, 0]); // 1
1861
+ cosineSimilarity([1, 0, 0], [0, 1, 0]); // 0
1862
+ cosineSimilarity([1, 2, 3], [1, 2]); // throws — dimension mismatch
1863
+ ```
1864
+
1865
+ Powers the brute-force `cache.similar()` on the memory drivers — reach for it
1866
+ directly only when scoring vectors outside the cache.
1867
+
1868
+ ## TTL constants — `CACHE_FOR`
1869
+
1870
+ ```ts
1871
+ import { cache, CACHE_FOR } from "@warlock.js/cache";
1872
+
1873
+ await cache.set("report", data, CACHE_FOR.ONE_WEEK);
1874
+ ```
1875
+
1876
+ Members: `HALF_HOUR`, `ONE_HOUR`, `HALF_DAY`, `ONE_DAY`, `ONE_WEEK`,
1877
+ `HALF_MONTH`, `ONE_MONTH`, `TWO_MONTHS`, `SIX_MONTHS`, `ONE_YEAR` (all seconds).
1878
+ For most call sites the duration string (`"1h"`, `"7d"`) reads better.
1879
+
1880
+ ## See also
1881
+
1882
+ - [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — the high-level `set` options these normalize
1883
+ - [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — `cache.similar()`, which uses `cosineSimilarity`
1884
+ - [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — building a custom driver where these help
1867
1885
 
1868
1886
 
1869
1887
  ## use-cached-hof `@warlock.js/cache/use-cached-hof/SKILL.md`
1870
1888
 
1871
- ---
1872
- name: use-cached-hof
1873
- description: "cached() — function-memoization wrapper in @warlock.js/cache; use when you need to use cached hof."
1874
- ---
1875
-
1876
- # `cached()` — function-memoization wrapper
1877
-
1878
- `cached()` turns any async function into a memoized version. One declaration, many call sites, with a bound `.invalidate()` helper.
1879
-
1880
- ## When to use
1881
-
1882
- Reach for `cached()` instead of `cache.remember()` when:
1883
- - You have a function you'll call from many places.
1884
- - You want the caching strategy declared once, not repeated at every call site.
1885
- - You want `.invalidate()` available without manually deriving keys.
1886
-
1887
- Stick with `cache.remember()` for one-shot "get-or-compute" calls.
1888
-
1889
- ## The three shapes — `fn` always first
1890
-
1891
- ```ts
1892
- import { cached } from "@warlock.js/cache";
1893
-
1894
- // 1. Prefix shorthand — driver default TTL; key auto-derived from args
1895
- cached(fn, "user");
1896
-
1897
- // 2. Prefix + TTL
1898
- cached(fn, "user", "1h");
1899
-
1900
- // 3. Options form — custom key fn, tags, per-call driver
1901
- cached(fn, {
1902
- key: (id: number) => `user.${id}`,
1903
- ttl: "1h",
1904
- tags: ["users"],
1905
- driver: "redis",
1906
- });
1907
- ```
1908
-
1909
- ## Auto-key rules (shorthand only)
1910
-
1889
+ ---
1890
+ name: use-cached-hof
1891
+ 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`.'
1892
+ ---
1893
+
1894
+ # `cached()` — function-memoization wrapper
1895
+
1896
+ `cached()` turns any async function into a memoized version. One declaration, many call sites, with a bound `.invalidate()` helper.
1897
+
1898
+ ## When to use
1899
+
1900
+ Reach for `cached()` instead of `cache.remember()` when:
1901
+ - You have a function you'll call from many places.
1902
+ - You want the caching strategy declared once, not repeated at every call site.
1903
+ - You want `.invalidate()` available without manually deriving keys.
1904
+
1905
+ Stick with `cache.remember()` for one-shot "get-or-compute" calls.
1906
+
1907
+ ## The three shapes — `fn` always first
1908
+
1909
+ ```ts
1910
+ import { cached } from "@warlock.js/cache";
1911
+
1912
+ // 1. Prefix shorthand — driver default TTL; key auto-derived from args
1913
+ cached(fn, "user");
1914
+
1915
+ // 2. Prefix + TTL
1916
+ cached(fn, "user", "1h");
1917
+
1918
+ // 3. Options form — custom key fn, tags, per-call driver
1919
+ cached(fn, {
1920
+ key: (id: number) => `user.${id}`,
1921
+ ttl: "1h",
1922
+ tags: ["users"],
1923
+ driver: "redis",
1924
+ });
1925
+ ```
1926
+
1927
+ ## Auto-key rules (shorthand only)
1928
+
1911
1929
  | Args | Key |
1912
1930
  |------|-----|
1913
1931
  | None | `prefix` |
1914
- | All primitives (incl. `null` / `undefined` / `bigint`) | `prefix.` + args joined with dots |
1915
- | Any object / array arg | `prefix.` + `JSON.stringify(args)` |
1916
- | Unserializable (circular / `BigInt` nested in object) | throws `CacheConfigurationError` |
1917
-
1918
- 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.
1919
-
1920
- ## Return shape
1921
-
1922
- ```ts
1923
- type CachedFn<Args, R> = ((...args: Args) => Promise<R>) & {
1924
- invalidate(...args: Args): Promise<void>;
1925
- };
1926
- ```
1927
-
1928
- `.refresh()` and `.peek()` are deferred to v2.1 — file demand in `backlog.md` if you need them.
1929
-
1930
- ## Recipes
1931
-
1932
- ### Cached DB lookup with write-side invalidation
1933
-
1934
- ```ts
1935
- const getUser = cached((id: number) => db.users.find(id), "user", "1h");
1936
-
1937
- // On update
1938
- await db.users.update(42, patch);
1939
- await getUser.invalidate(42);
1940
- ```
1941
-
1942
- ### Tag-based bulk invalidation across wrappers
1943
-
1944
- ```ts
1945
- const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
1946
- const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
1947
-
1948
- await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
1949
- ```
1950
-
1951
- See [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md).
1952
-
1953
- ### Project a subset of args into the key
1954
-
1955
- ```ts
1956
- const getCategoryMeta = cached(
1957
- (filters: Filters) => db.categories.meta(filters.category),
1958
- { key: (f) => `category.meta.${f.category}`, ttl: "1h" }, // ignores `sort`, `page`
1959
- );
1960
- ```
1961
-
1962
- ## Interaction with the rest of the API
1963
-
1964
- - Uses `cache.remember()` internally → inherits stampede protection within a single Node process.
1965
- - Forwards `tags` and `driver` through the (extended) `RememberOptions` shape on `remember`.
1966
- - `.invalidate()` calls `cache.remove()` — no side effects beyond the single entry.
1967
-
1968
- ## Things NOT to do
1969
-
1970
- - 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.
1971
- - Don't rely on cross-process stampede safety. `cached` inherits `remember`'s in-process lock; cross-process needs a distributed lock via `onConflict: "create"`.
1972
- - Don't include secrets in args — they'd land in cache keys. Project only the identifying fields into the key.
1932
+ | Any args | `prefix.<16 hex>` — sha1 of a stable JSON encoding of the args, truncated |
1933
+ | `Map` / `Set` / function / symbol / class instance / circular | throws `CacheConfigurationError` |
1934
+
1935
+ The hash keeps distinct args on distinct keys (`("1.private", "")` ≠ `("1", "private")`). Object keys are sorted; `Date` → ISO string; order of args matters. Never build keys by hand: call `.invalidate(...args)`, which honours `config.driver`. When auto-key fails, use the options form with a custom `key` fn.
1936
+
1937
+ **Upgrade (5.20):** the key format changed from dot-joined args, so old entries are never read and every call misses once.
1938
+
1939
+ ## Return shape
1940
+
1941
+ ```ts
1942
+ type CachedFn<Args, R> = ((...args: Args) => Promise<R>) & {
1943
+ invalidate(...args: Args): Promise<void>;
1944
+ };
1945
+ ```
1946
+
1947
+ `.refresh()` and `.peek()` are deferred to v2.1 — file demand in `backlog.md` if you need them.
1948
+
1949
+ ## Recipes
1950
+
1951
+ ### Cached DB lookup with write-side invalidation
1952
+
1953
+ ```ts
1954
+ const getUser = cached((id: number) => db.users.find(id), "user", "1h");
1955
+
1956
+ // On update
1957
+ await db.users.update(42, patch);
1958
+ await getUser.invalidate(42);
1959
+ ```
1960
+
1961
+ ### Tag-based bulk invalidation across wrappers
1962
+
1963
+ ```ts
1964
+ const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
1965
+ const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
1966
+
1967
+ await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
1968
+ ```
1969
+
1970
+ See [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md).
1971
+
1972
+ ### Project a subset of args into the key
1973
+
1974
+ ```ts
1975
+ const getCategoryMeta = cached(
1976
+ (filters: Filters) => db.categories.meta(filters.category),
1977
+ { key: (f) => `category.meta.${f.category}`, ttl: "1h" }, // ignores `sort`, `page`
1978
+ );
1979
+ ```
1980
+
1981
+ ## Interaction with the rest of the API
1982
+
1983
+ - Uses `cache.remember()` internally → inherits stampede protection within a single Node process.
1984
+ - Forwards `tags` and `driver` through the (extended) `RememberOptions` shape on `remember`.
1985
+ - `.invalidate()` calls `cache.remove()` — no side effects beyond the single entry.
1986
+
1987
+ ## Things NOT to do
1988
+
1989
+ - Don't wrap a function whose args include `Map`, `Set` or class instances with the shorthand form (it throws). Use the options form and project a stable subset into the key.
1990
+ - Don't rely on cross-process stampede safety. `cached` inherits `remember`'s in-process lock; cross-process needs a distributed lock via `onConflict: "create"`.
1991
+ - Don't include secrets in args — they'd land in cache keys. Project only the identifying fields into the key.
1973
1992
 
1974
1993
 
1975
1994
  ## use-swr `@warlock.js/cache/use-swr/SKILL.md`
1976
1995
 
1977
- ---
1978
- name: use-swr
1979
- description: "Stale-while-revalidate — cache.swr(key, options, fn) in @warlock.js/cache; use when you need to use swr."
1980
- ---
1981
-
1982
- # Stale-while-revalidate — `cache.swr(key, options, fn)`
1983
-
1984
- 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.
1985
-
1986
- ## When to reach for it
1987
-
1988
- 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.
1989
-
1990
- 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).
1991
-
1992
- ## Three windows
1993
-
1994
- ```
1995
- write freshTtl staleTtl
1996
- │ │ │
1997
- ▼ ▼ ▼
1998
- ─────┬──── fresh ─────────┬──── stale ─────────┬──── expired ──→
1999
- │ return cached │ return cached + │ block, refetch
2000
- │ no upstream call │ bg refresh │ like a miss
2001
- ```
2002
-
2003
- | Window | Behavior |
2004
- |---|---|
2005
- | `now < freshTtl` | Return cached. No upstream call. |
2006
- | `freshTtl ≤ now < staleTtl` | Return cached immediately. Run `fn()` in background; next read sees the refreshed value. |
2007
- | `now ≥ staleTtl` | Block on `fn()`. Same as `remember()`. |
2008
-
2009
- ## API
2010
-
2011
- ```ts
2012
- await cache.swr(
2013
- "product.42",
2014
- {
2015
- freshTtl: "1m", // CacheTtl — within this, no upstream call
2016
- staleTtl: "1h", // CacheTtl — past this, block-and-refetch
2017
- tags?: string[], // applied on first miss + every successful refresh
2018
- driver?: string, // per-call driver override, like remember()
2019
- },
2020
- () => db.products.find(42),
2021
- );
2022
- ```
2023
-
2024
- `staleTtl` MUST be greater than `freshTtl` — otherwise throws.
2025
-
2026
- ## Key invariants
2027
-
2028
- 1. **Concurrent stale-window callers share one refresh.** Per-key dedupe via the driver's existing locks map — no thundering herd on background refresh.
2029
- 2. **Failed background refreshes preserve the stale entry.** No retry storm; the next stale-window read tries again. Failures emit `error` events for observability.
2030
- 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).
2031
- 4. **Tags compose.** Per-call tags + scope tags (when via `cache.namespace().swr(...)`) merge additively.
2032
- 5. **Scope `ttl` defaults are NOT applied to SWR.** `freshTtl` / `staleTtl` always come from the call site.
2033
-
2034
- ## Driver support
2035
-
2036
- | Driver | Background refresh |
2037
- |---|---|
2038
- | memory / memoryExtended / lru / file / mock | ✅ Full |
2039
- | redis | ✅ Full (sidecar key for staleAt — backwards-compatible) |
2040
- | pg | ✅ Full (`stale_at TIMESTAMPTZ` column — provision via `driver.schema()`) |
2041
- | null | ❌ Always-fetch (null caches nothing) |
2042
-
2043
- ## Common shapes
2044
-
2045
- ```ts
2046
- // Product detail — slightly stale OK, never want to block on DB
2047
- await cache.swr(`product.${id}`, { freshTtl: "1m", staleTtl: "1h" }, () =>
2048
- db.products.findById(id),
2049
- );
2050
-
2051
- // Dashboard — expensive aggregation, OK to be 5min stale
2052
- await cache.swr(`dashboard.${tenantId}`, { freshTtl: "5m", staleTtl: "1h" }, () =>
2053
- computeKPIs(tenantId),
2054
- );
2055
-
2056
- // Third-party API — degrade gracefully when upstream is down
2057
- await cache.swr("exchange.rates", { freshTtl: "10m", staleTtl: "24h" }, () =>
2058
- fetchFromForexAPI(),
2059
- );
2060
- ```
2061
-
2062
- ## Through scoped caches
2063
-
2064
- ```ts
2065
- const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
2066
-
2067
- await feed.swr(
2068
- "home",
2069
- { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
2070
- () => buildHomeFeed(userId),
2071
- );
2072
- // stored at feed.<userId>.home, tagged [user.<userId>, computed]
2073
- ```
2074
-
2075
- ## Things NOT to do
2076
-
2077
- - 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.
2078
- - 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.
2079
- - Don't ignore `error` events. A persistent stream of refresh failures means upstream is broken and the cache is masking it.
2080
- - Don't reach for SWR on the null driver — it caches nothing, so SWR always blocks.
1996
+ ---
1997
+ name: use-swr
1998
+ 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).'
1999
+ ---
2000
+
2001
+ # Stale-while-revalidate — `cache.swr(key, options, fn)`
2002
+
2003
+ 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.
2004
+
2005
+ ## When to reach for it
2006
+
2007
+ 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.
2008
+
2009
+ 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).
2010
+
2011
+ ## Three windows
2012
+
2013
+ ```
2014
+ write freshTtl staleTtl
2015
+ │ │ │
2016
+ ▼ ▼ ▼
2017
+ ─────┬──── fresh ─────────┬──── stale ─────────┬──── expired ──→
2018
+ │ return cached │ return cached + │ block, refetch
2019
+ │ no upstream call │ bg refresh │ like a miss
2020
+ ```
2021
+
2022
+ | Window | Behavior |
2023
+ |---|---|
2024
+ | `now < freshTtl` | Return cached. No upstream call. |
2025
+ | `freshTtl ≤ now < staleTtl` | Return cached immediately. Run `fn()` in background; next read sees the refreshed value. |
2026
+ | `now ≥ staleTtl` | Block on `fn()`. Same as `remember()`. |
2027
+
2028
+ ## API
2029
+
2030
+ ```ts
2031
+ await cache.swr(
2032
+ "product.42",
2033
+ {
2034
+ freshTtl: "1m", // CacheTtl — within this, no upstream call
2035
+ staleTtl: "1h", // CacheTtl — past this, block-and-refetch
2036
+ tags?: string[], // applied on first miss + every successful refresh
2037
+ driver?: string, // per-call driver override, like remember()
2038
+ },
2039
+ () => db.products.find(42),
2040
+ );
2041
+ ```
2042
+
2043
+ `staleTtl` MUST be greater than `freshTtl` — otherwise throws.
2044
+
2045
+ ## Key invariants
2046
+
2047
+ 1. **Concurrent stale-window callers share one refresh.** Per-key dedupe via the driver's existing locks map — no thundering herd on background refresh.
2048
+ 2. **Failed background refreshes preserve the stale entry.** No retry storm; the next stale-window read tries again. Failures emit `error` events for observability.
2049
+ 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).
2050
+ 4. **Tags compose.** Per-call tags + scope tags (when via `cache.namespace().swr(...)`) merge additively.
2051
+ 5. **Scope `ttl` defaults are NOT applied to SWR.** `freshTtl` / `staleTtl` always come from the call site.
2052
+
2053
+ ## Driver support
2054
+
2055
+ | Driver | Background refresh |
2056
+ |---|---|
2057
+ | memory / memoryExtended / lru / file / mock | ✅ Full |
2058
+ | redis | ✅ Full (sidecar key for staleAt — backwards-compatible) |
2059
+ | pg | ✅ Full (`stale_at TIMESTAMPTZ` column — provision via `driver.schema()`) |
2060
+ | null | ❌ Always-fetch (null caches nothing) |
2061
+
2062
+ ## Common shapes
2063
+
2064
+ ```ts
2065
+ // Product detail — slightly stale OK, never want to block on DB
2066
+ await cache.swr(`product.${id}`, { freshTtl: "1m", staleTtl: "1h" }, () =>
2067
+ db.products.findById(id),
2068
+ );
2069
+
2070
+ // Dashboard — expensive aggregation, OK to be 5min stale
2071
+ await cache.swr(`dashboard.${tenantId}`, { freshTtl: "5m", staleTtl: "1h" }, () =>
2072
+ computeKPIs(tenantId),
2073
+ );
2074
+
2075
+ // Third-party API — degrade gracefully when upstream is down
2076
+ await cache.swr("exchange.rates", { freshTtl: "10m", staleTtl: "24h" }, () =>
2077
+ fetchFromForexAPI(),
2078
+ );
2079
+ ```
2080
+
2081
+ ## Through scoped caches
2082
+
2083
+ ```ts
2084
+ const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
2085
+
2086
+ await feed.swr(
2087
+ "home",
2088
+ { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
2089
+ () => buildHomeFeed(userId),
2090
+ );
2091
+ // stored at feed.<userId>.home, tagged [user.<userId>, computed]
2092
+ ```
2093
+
2094
+ ## Things NOT to do
2095
+
2096
+ - 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.
2097
+ - 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.
2098
+ - Don't ignore `error` events. A persistent stream of refresh failures means upstream is broken and the cache is masking it.
2099
+ - Don't reach for SWR on the null driver — it caches nothing, so SWR always blocks.
2081
2100
 
2082
2101