@warlock.js/cache 5.23.2 → 5.25.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 (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +616 -616
  3. package/cjs/index.cjs +21 -1
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/drivers/base-cache-driver.d.mts +12 -0
  6. package/esm/drivers/base-cache-driver.d.mts.map +1 -1
  7. package/esm/drivers/base-cache-driver.mjs +10 -0
  8. package/esm/drivers/base-cache-driver.mjs.map +1 -1
  9. package/esm/drivers/null-cache-driver.mjs.map +1 -1
  10. package/esm/tagged-cache.d.mts.map +1 -1
  11. package/esm/tagged-cache.mjs +11 -1
  12. package/esm/tagged-cache.mjs.map +1 -1
  13. package/esm/types.d.mts +5 -2
  14. package/esm/types.d.mts.map +1 -1
  15. package/esm/types.mjs.map +1 -1
  16. package/esm/utils.mjs.map +1 -1
  17. package/llms-full.txt +1632 -1631
  18. package/llms.txt +1 -1
  19. package/package.json +3 -3
  20. package/skills/apply-cache-patterns/SKILL.md +95 -95
  21. package/skills/configure-set-options/SKILL.md +96 -96
  22. package/skills/handle-cache-errors/SKILL.md +92 -92
  23. package/skills/observe-cache/SKILL.md +104 -103
  24. package/skills/pick-cache-driver/SKILL.md +121 -121
  25. package/skills/test-cache-code/SKILL.md +219 -219
  26. package/skills/use-cache-atomic/SKILL.md +42 -42
  27. package/skills/use-cache-bulk/SKILL.md +57 -57
  28. package/skills/use-cache-list/SKILL.md +85 -85
  29. package/skills/use-cache-lock/SKILL.md +101 -101
  30. package/skills/use-cache-namespace/SKILL.md +88 -88
  31. package/skills/use-cache-similarity/SKILL.md +94 -94
  32. package/skills/use-cache-update-merge/SKILL.md +84 -84
  33. package/skills/use-cache-utils/SKILL.md +89 -89
  34. package/skills/use-cached-hof/SKILL.md +93 -93
  35. package/skills/use-swr/SKILL.md +104 -104
package/llms-full.txt CHANGED
@@ -6,104 +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: '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.
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
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
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
-
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
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
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
107
107
 
108
108
 
109
109
  ## cache-basics `@warlock.js/cache/cache-basics/SKILL.md`
@@ -355,311 +355,312 @@ See [`@warlock.js/cache/handle-cache-errors/SKILL.md`](@warlock.js/cache/handle-
355
355
 
356
356
  ## configure-set-options `@warlock.js/cache/configure-set-options/SKILL.md`
357
357
 
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
-
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
397
  ## TTL parsing
398
398
 
399
399
  - Number or bare numeric string (`"3600"`) = **seconds**. Fractions and sub-second values (`0.5`, `"500ms"`) round **up** to whole seconds, never 0.
400
400
  - `0` / `Infinity` = no expiry. `NaN`, negative and unparseable values throw `CacheConfigurationError`.
401
401
  - `cache.lock()` refuses any TTL that never expires.
402
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
- ```
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
+ ```
460
460
 
461
461
 
462
462
  ## handle-cache-errors `@warlock.js/cache/handle-cache-errors/SKILL.md`
463
463
 
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.
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.
556
556
 
557
557
 
558
558
  ## observe-cache `@warlock.js/cache/observe-cache/SKILL.md`
559
559
 
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()`.
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`, `invalidated`, `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
+ | See which tag invalidations ran, what they removed and how long they took | `cache.on("invalidated", ({ tags, keys, durationMs }) => ...)` — once per `tags([...]).invalidate()` |
619
+ | Detect a specific anti-pattern (e.g. always-miss key) | `cache.on("miss", ...)` |
620
+ | Debug "is the cache being hit at all?" in dev | `cache.metrics()` once at the end of a flow |
621
+
622
+ Both layers can coexist — events fire whether the metrics collector is attached or not.
623
+
624
+ ## Common shapes
625
+
626
+ ### Periodic export, then reset
627
+
628
+ ```ts
629
+ setInterval(() => {
630
+ const snapshot = cache.metrics();
631
+ exporter.send(snapshot);
632
+ cache.resetMetrics();
633
+ }, 60_000);
634
+ ```
635
+
636
+ The snapshot now reflects the last minute of traffic, not the lifetime.
637
+
638
+ ### Boundary measurement
639
+
640
+ ```ts
641
+ cache.resetMetrics();
642
+ await runTrafficBurst();
643
+ console.log(cache.metrics());
644
+ ```
645
+
646
+ Useful for benchmarks, soak tests, "did the cache help?" before/after comparisons.
647
+
648
+ ### Per-driver isolation
649
+
650
+ ```ts
651
+ const m = cache.metrics();
652
+ console.log(`memory hit rate: ${m.byDriver.memory?.hitRate ?? 0}`);
653
+ console.log(`redis p95: ${m.byDriver.redis?.latencyMs.p95 ?? 0}ms`);
654
+ ```
655
+
656
+ Drivers that never fire events stay absent from `byDriver` — guard with `?.` and `?? 0`.
657
+
658
+ ## Things NOT to do
659
+
660
+ - Don't subscribe to events to count things and ignore the built-in collector — that's exactly what it's built to do.
661
+ - 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.
662
+ - 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.
663
+ - Don't forget to attach early. If startup metrics matter, call `cache.metrics()` right after `cache.init()`.
663
664
 
664
665
 
665
666
  ## overview `@warlock.js/cache/overview/SKILL.md`
@@ -737,939 +738,939 @@ Nineteen task skills. Most apps start with `cache-basics` + `pick-cache-driver`
737
738
 
738
739
  ## pick-cache-driver `@warlock.js/cache/pick-cache-driver/SKILL.md`
739
740
 
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) |
741
+ ---
742
+ name: pick-cache-driver
743
+ 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`.'
744
+ ---
745
+
746
+ # Cache drivers — pick the right one
747
+
748
+ Seven production drivers + a mock driver ship in-box. Pick by durability, scope, and workload.
749
+
750
+ | Driver | Process scope | Persists on restart | Good for | Avoid when |
751
+ | --- | --- | --- | --- | --- |
752
+ | `null` | — | — | Disabling cache in tests; feature-flagging off | You actually want caching |
753
+ | `memory` | Single process | No | Hot in-process data with default TTL; smallest latency | Multi-process / multi-node |
754
+ | `memoryExtended` | Single process | No | Sliding-window TTL (TTL resets on every read) | Any multi-process deploy |
755
+ | `lru` | Single process | No | Bounded in-memory caches (capacity-based eviction) | Need cross-process sharing |
756
+ | `file` | Single host | Yes | Build artefacts, local dev persistence across restarts | Concurrency (no locks); multi-host |
757
+ | `redis` | Shared | Yes (Redis-managed) | Anything shared across processes / nodes | Single-process-only workload — overkill |
758
+ | `pg` | Shared | Yes (Postgres-managed) | You already run Postgres; semantic caching / RAG via pgvector | High-throughput hot reads (Redis is faster) |
759
+
760
+ ## Capability matrix
761
+
762
+ | Capability | null | memory | memoryExt | lru | file | redis | pg |
763
+ | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
764
+ | `set` / `get` / `remove` / `flush` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
765
+ | TTL (number or string; numeric strings = seconds) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
766
+ | Sliding TTL on read | — | — | ✓ | — | — | — | — |
767
+ | `removeNamespace` | noop | ✓ | ✓ | ✓ (prefix-scan) | ✓ | ✓ | ✓ (LIKE prefix) |
768
+ | `onConflict: "create"` / `"update"` | noop | emulated | emulated | emulated | emulated | native `NX`/`XX` | native (INSERT ON CONFLICT) |
768
769
  | `increment` / `decrement` / `pull` | — | one process | one process | one process | one process | **cross-server** | **cross-server** |
769
770
  | `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
-
771
+ | List sub-API | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (O(n) JSON blob today; native LPUSH/LRANGE in v2.1) | ✓ |
772
+ | Tagged invalidation | noop | ✓ | ✓ | ✓ | ✓ (serialized index) | ✓ (native SET) | ✓ (JSON-array merge) |
773
+ | `similar()` / `set({ vector })` | returns `[]` / noop | ✓ brute force | ✓ brute force | ✓ brute force | ✗ throws | ✗ throws (Phase 2 backlog) | ✓ (with `vector` config — pgvector) |
774
+
774
775
  ## Multi-server: use redis or pg
775
776
 
776
777
  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
 
778
779
  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
 
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
+ ## Global config TTL — accepts number or string
869
782
 
783
+ ```ts
784
+ options: {
785
+ redis: { url: "...", ttl: "7d" }, // string OK
786
+ memory: { ttl: 3600 }, // number OK
787
+ lru: { capacity: 10_000 }, // LRU has no TTL option today
788
+ file: { directory: () => "/var/cache/myapp", ttl: "1h" },
789
+ pg: { client: pool, ttl: "1h" }, // KV-only
790
+ // pg with pgvector:
791
+ // pg: { client: pool, vector: { dimensions: 1536, index: "hnsw" } },
792
+ }
793
+ ```
870
794
 
871
- ## test-cache-code `@warlock.js/cache/test-cache-code/SKILL.md`
795
+ ## Global prefix (multi-tenant scoping)
872
796
 
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.
797
+ 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:
1092
798
 
799
+ ```ts
800
+ options: {
801
+ redis: {
802
+ url: "...",
803
+ globalPrefix: () => `tenant-${currentContext.tenantId}`,
804
+ },
805
+ }
806
+ ```
1093
807
 
1094
- ## use-cache-atomic `@warlock.js/cache/use-cache-atomic/SKILL.md`
808
+ ## Key & namespace safety (file / redis)
1095
809
 
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
810
+ 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:
1120
811
 
1121
- `increment`, `decrement`, `pull` and `update` are atomic, but only some drivers are atomic **across servers**:
812
+ - **`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`.
813
+ - **`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.
814
+ - **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).
1122
815
 
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) |
816
+ ## Registering a custom driver
1129
817
 
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.
818
+ ```ts
819
+ import { BaseCacheDriver, cache } from "@warlock.js/cache";
1131
820
 
1132
- ## TTL is kept
821
+ class MemcachedCacheDriver extends BaseCacheDriver<MyClient, MyOptions> {
822
+ public name = "memcached";
823
+ // … implement set / get / remove / flush / removeNamespace / connect
824
+ }
1133
825
 
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.
826
+ cache.setCacheConfigurations({
827
+ default: "memcached",
828
+ drivers: { memcached: MemcachedCacheDriver },
829
+ options: { memcached: { host: "localhost" } },
830
+ });
831
+ ```
1135
832
 
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
833
+ Extending `BaseCacheDriver` gives you free: TTL parsing, key parsing, event emission, stampede-safe `remember`, deep-clone-on-read, default `update` / `merge` / `list` implementations.
1155
834
 
835
+ ## Runtime driver options — `cache.use(name, options)`
1156
836
 
1157
- ## use-cache-bulk `@warlock.js/cache/use-cache-bulk/SKILL.md`
837
+ 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.
1158
838
 
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
839
+ ```ts
840
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
1216
841
 
842
+ cache.setCacheConfigurations({
843
+ default: "pg",
844
+ drivers: { pg: PgCacheDriver },
845
+ options: { pg: { table: "cache" } }, // static
846
+ });
1217
847
 
1218
- ## use-cache-list `@warlock.js/cache/use-cache-list/SKILL.md`
848
+ await cache.use("pg", { client: pool }); // runtime — skip init() in this case
849
+ ```
1219
850
 
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.
851
+ Constraints:
852
+ - The driver name must be registered in `setCacheConfigurations({ drivers })` — runtime options don't bypass registration.
853
+ - 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.
854
+ - Calling without options (or with `{}`) on an already-loaded driver returns the cached instance silently.
1305
855
 
856
+ ## Per-call driver override
1306
857
 
1307
- ## use-cache-lock `@warlock.js/cache/use-cache-lock/SKILL.md`
858
+ When most writes go to the default driver but one call needs a different one:
1308
859
 
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.
860
+ ```ts
861
+ await cache.set("audit:event", event, { driver: "redis" });
862
+ ```
1337
863
 
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`.
864
+ The manager loads (and connects) the override driver lazily on first use, then routes that single operation through it without mutating `currentDriver`.
1339
865
 
1340
- ## Lock value and release
866
+ ## See also
1341
867
 
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.
868
+ - [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md) — full pg setup (KV-only and pgvector mode)
869
+ - [`@warlock.js/cache/test-cache-code/SKILL.md`](@warlock.js/cache/test-cache-code/SKILL.md) — `MockCacheDriver` and `NullCacheDriver` for tests
1422
870
 
1423
871
 
1424
- ## use-cache-namespace `@warlock.js/cache/use-cache-namespace/SKILL.md`
872
+ ## test-cache-code `@warlock.js/cache/test-cache-code/SKILL.md`
1425
873
 
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.
874
+ ---
875
+ name: test-cache-code
876
+ 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`.'
877
+ ---
1514
878
 
879
+ # Testing code that touches cache
1515
880
 
1516
- ## use-cache-similarity `@warlock.js/cache/use-cache-similarity/SKILL.md`
881
+ Three good strategies — pick based on what you're testing.
1517
882
 
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.
883
+ ## Strategy 1 — `MockCacheDriver` for behavioral assertions (preferred)
1612
884
 
885
+ 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:
1613
886
 
1614
- ## use-cache-tags `@warlock.js/cache/use-cache-tags/SKILL.md`
887
+ - `wasCalled(operation, key?)` — was a given op invoked? Optional key matched post-`parseKey`.
888
+ - `getStored(key)` — raw stored value, bypassing TTL handling and clone protection.
889
+ - `reset()` — wipe storage, tag index, and call log in one call.
890
+ - `callLog: CacheCall[]` — ordered record of every op (operation, parsed key, raw args, timestamp).
1615
891
 
1616
- ---
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`.'
1619
- ---
892
+ ```ts
893
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
894
+ import { cache, MockCacheDriver } from "@warlock.js/cache";
1620
895
 
1621
- # Tag-based invalidation
896
+ describe("UserService.update", () => {
897
+ let driver: MockCacheDriver;
1622
898
 
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.
899
+ beforeEach(async () => {
900
+ cache.setCacheConfigurations({
901
+ default: "mock",
902
+ logging: false,
903
+ drivers: { mock: MockCacheDriver },
904
+ options: { mock: {} },
905
+ });
906
+ await cache.init();
1624
907
 
1625
- ## Attach tags on write
908
+ driver = cache.currentDriver as MockCacheDriver;
909
+ });
1626
910
 
1627
- ```ts
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"] });
911
+ afterEach(async () => {
912
+ driver.reset();
913
+ await cache.disconnect();
914
+ });
1631
915
 
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
- ```
916
+ it("invalidates the user cache after update", async () => {
917
+ await new UserService().update(42, { name: "Jane" });
1637
918
 
1638
- ## Invalidate
919
+ expect(driver.wasCalled("remove", "users.42")).toBe(true);
920
+ });
1639
921
 
1640
- ```ts
1641
- // Drop everything tagged "users"
1642
- await cache.tags(["users"]).invalidate();
922
+ it("caches with the right TTL on read-through", async () => {
923
+ await new UserService().getProfile(1);
1643
924
 
1644
- // Multi-tag — matches either tag (union)
1645
- await cache.tags(["tenant-42"]).invalidate();
925
+ const setCall = driver.callLog.find((call) => call.operation === "set");
926
+ expect(setCall?.args[1]).toBe("1h");
927
+ });
928
+ });
1646
929
  ```
1647
930
 
1648
- Multi-tag is **union** semantics: an entry is invalidated if it carries **at least one** of the listed tags.
931
+ `wasCalled` normalizes object keys, so `wasCalled("set", { id: 1 })` and `wasCalled("set", "id.1")` match the same call.
1649
932
 
1650
- ## When to reach for tags vs namespaces
933
+ ## Strategy 2 — `MemoryCacheDriver` for full-stack integration tests
1651
934
 
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 |
935
+ 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`.
1657
936
 
1658
- Namespaces are cheaper (no reverse index). Tags are more powerful (any key can carry any tag).
937
+ `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.
1659
938
 
1660
- ## Inline tag semantics
939
+ ## Strategy 3 — `NullCacheDriver` when you need cache *off*
1661
940
 
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.
941
+ Use `NullCacheDriver` to disable caching entirely for code paths that should still work without a cache (graceful-degradation tests):
1663
942
 
1664
- ## Driver behavior
943
+ ```ts
944
+ cache.setCacheConfigurations({
945
+ default: "null",
946
+ drivers: { null: NullCacheDriver },
947
+ options: { null: {} },
948
+ });
949
+ await cache.init();
1665
950
 
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) |
951
+ // All cache ops no-op; get() always returns null; set() silently discards.
952
+ ```
953
+
954
+ ## Mocking Redis (for driver-level tests, not app code)
955
+
956
+ For tests that specifically exercise `RedisCacheDriver`, use `vi.mock("redis")` with an in-memory fake. Example (condensed from `redis-cache-driver.spec.ts`):
957
+
958
+ ```ts
959
+ import { vi } from "vitest";
960
+
961
+ class FakeRedisClient {
962
+ public store = new Map<string, string>();
963
+ private expires = new Map<string, number>();
964
+ public on() { return this; }
965
+ public async connect() {}
966
+ public async quit() {}
967
+ public async set(key: string, value: string, opts?: { EX?: number; NX?: boolean; XX?: boolean }) {
968
+ if (opts?.NX && this.store.has(key)) return null;
969
+ if (opts?.XX && !this.store.has(key)) return null;
970
+ this.store.set(key, value);
971
+ if (opts?.EX) this.expires.set(key, Date.now() + opts.EX * 1000);
972
+ return "OK";
973
+ }
974
+ public async get(key: string) {
975
+ const ttl = this.expires.get(key);
976
+ if (ttl && ttl < Date.now()) {
977
+ this.store.delete(key);
978
+ return null;
979
+ }
980
+ return this.store.get(key) ?? null;
981
+ }
982
+ public async del(keys: string | string[]) {
983
+ const arr = Array.isArray(keys) ? keys : [keys];
984
+ let count = 0;
985
+ for (const k of arr) if (this.store.delete(k)) count++;
986
+ return count;
987
+ }
988
+ public async keys(pattern: string) {
989
+ const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
990
+ return [...this.store.keys()].filter((k) => regex.test(k));
991
+ }
992
+ public async flushAll() { this.store.clear(); this.expires.clear(); }
993
+ public async incrBy(key: string, n: number) {
994
+ const next = Number(this.store.get(key) ?? 0) + n;
995
+ this.store.set(key, String(next));
996
+ return next;
997
+ }
998
+ public async decrBy(key: string, n: number) { return this.incrBy(key, -n); }
999
+ }
1000
+
1001
+ const fakeClient = new FakeRedisClient();
1002
+ vi.mock("redis", () => ({ createClient: vi.fn(() => fakeClient) }));
1003
+ ```
1004
+
1005
+ **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:
1006
+
1007
+ ```ts
1008
+ async function importDriver() {
1009
+ const mod = await import("./redis-cache-driver");
1010
+ await new Promise((resolve) => setTimeout(resolve, 250));
1011
+ return mod.RedisCacheDriver;
1012
+ }
1013
+ ```
1014
+
1015
+ ## Silencing cache logs in tests
1016
+
1017
+ The in-package vitest config runs `silent: true`, but if you're outside the package, explicitly disable logging:
1018
+
1019
+ ```ts
1020
+ cache.setCacheConfigurations({
1021
+ default: "memory",
1022
+ logging: false, // <-- here
1023
+ drivers: { memory: MemoryCacheDriver },
1024
+ options: { memory: {} },
1025
+ });
1026
+ ```
1027
+
1028
+ Or per-driver: `driver.setLoggingState(false)`.
1029
+
1030
+ ## Spying on events
1031
+
1032
+ Every driver emits `hit`, `miss`, `set`, `removed`, `flushed`, `expired`. Attach listeners to assert cache behavior without inspecting internal state:
1033
+
1034
+ ```ts
1035
+ const hits = vi.fn();
1036
+ cache.on("hit", hits);
1037
+
1038
+ await service.getProfile("1");
1039
+ await service.getProfile("1");
1040
+
1041
+ expect(hits).toHaveBeenCalledTimes(1); // second call was a hit
1042
+ ```
1043
+
1044
+ Listeners registered via `cache.on(...)` automatically attach to any driver loaded later, so order of `on()` vs `init()` doesn't matter.
1045
+
1046
+ ## Tests for `update` / `merge` concurrency
1047
+
1048
+ The chain-serialization guarantee is worth testing when your code fans out concurrent updates:
1049
+
1050
+ ```ts
1051
+ await cache.set("counter", 0);
1052
+
1053
+ await Promise.all(
1054
+ Array.from({ length: 10 }, () =>
1055
+ cache.update<number>("counter", (c) => (c ?? 0) + 1),
1056
+ ),
1057
+ );
1058
+
1059
+ await expect(cache.get("counter")).resolves.toBe(10);
1060
+ ```
1061
+
1062
+ 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).
1063
+
1064
+ ## Testing similarity code paths
1065
+
1066
+ `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):
1067
+
1068
+ ```ts
1069
+ beforeEach(async () => {
1070
+ cache.setCacheConfigurations({
1071
+ default: "memory",
1072
+ logging: false,
1073
+ drivers: { memory: MemoryCacheDriver },
1074
+ options: { memory: {} },
1075
+ });
1076
+ await cache.init();
1077
+ });
1078
+
1079
+ it("returns the most similar doc above threshold", async () => {
1080
+ await cache.set("a", { text: "alpha" }, { vector: [1, 0, 0] });
1081
+ await cache.set("b", { text: "beta" }, { vector: [0, 1, 0] });
1082
+
1083
+ const hits = await cache.similar([1, 0, 0], { topK: 1, threshold: 0.5 });
1084
+
1085
+ expect(hits).toHaveLength(1);
1086
+ expect(hits[0].key).toBe("a");
1087
+ });
1088
+ ```
1089
+
1090
+ ## Testing the `pg` driver without a real Postgres
1091
+
1092
+ 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.
1093
+
1094
+
1095
+ ## use-cache-atomic `@warlock.js/cache/use-cache-atomic/SKILL.md`
1096
+
1097
+ ---
1098
+ name: use-cache-atomic
1099
+ 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`.'
1100
+ ---
1101
+
1102
+ # Atomic counters — `cache.increment` / `cache.decrement`
1103
+
1104
+ Numeric counters that go up and down without a read-then-write race in your own
1105
+ code. Both return the **new** value after the operation.
1106
+
1107
+ ```ts
1108
+ import { cache } from "@warlock.js/cache";
1109
+
1110
+ const views = await cache.increment(`post.${id}.views`); // +1 → 1, 2, 3…
1111
+ const bulk = await cache.increment(`post.${id}.views`, 10); // +10
1112
+ const left = await cache.decrement(`stock.${sku}`, 3); // -3
1113
+ ```
1114
+
1115
+ - A missing key is treated as `0`, so the first `increment` returns `by` (default `1`).
1116
+ - `decrement(key, n)` is exactly `increment(key, -n)`.
1117
+ - The stored value must be numeric — incrementing a string/object throws:
1118
+ `Error: Cannot increment non-numeric value for key: <key>`.
1119
+
1120
+ ## Atomicity is per-driver
1121
+
1122
+ `increment`, `decrement`, `pull` and `update` are atomic, but only some drivers are atomic **across servers**:
1123
+
1124
+ | Driver | `increment` / `decrement` | `pull` | `update` / `merge` |
1125
+ |---|---|---|---|
1126
+ | `redis` | cross-server (`INCRBY`) | cross-server (`GETDEL`) | cross-server (Lua compare-and-set, retries) |
1127
+ | `pg` | cross-server (one upsert) | cross-server (`DELETE … RETURNING`) | cross-server (compare-and-set, retries) |
1128
+ | `memory` / `memoryExtended` / `lru` / `mock` | one process | one process | one process |
1129
+ | `file` | one process | one process | not supported (throws) |
1130
+
1131
+ 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.
1132
+
1133
+ ## TTL is kept
1134
+
1135
+ 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.
1136
+
1137
+ ## Common shapes
1138
+
1139
+ ```ts
1140
+ // View counter
1141
+ await cache.increment(`post.${id}.views`);
1142
+
1143
+ // Decrement stock, guard against oversell
1144
+ const remaining = await cache.decrement(`stock.${sku}`, qty);
1145
+ if (remaining < 0) {
1146
+ await cache.increment(`stock.${sku}`, qty); // roll back
1147
+ throw new Error("Out of stock");
1148
+ }
1149
+ ```
1150
+
1151
+ ## See also
1152
+
1153
+ - [`@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
1154
+ - [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md) — coordinate multi-step critical sections
1155
+ - [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — when you need cross-node atomicity
1156
+
1157
+
1158
+ ## use-cache-bulk `@warlock.js/cache/use-cache-bulk/SKILL.md`
1159
+
1160
+ ---
1161
+ name: use-cache-bulk
1162
+ 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`.'
1163
+ ---
1164
+
1165
+ # Bulk operations — `cache.many` / `cache.setMany`
1166
+
1167
+ Read or write a batch of keys in one call instead of awaiting them one at a time.
1168
+
1169
+ ## Read many — `many(keys)`
1170
+
1171
+ ```ts
1172
+ import { cache } from "@warlock.js/cache";
1173
+
1174
+ const [alice, bob, carol] = await cache.many(["user.1", "user.2", "user.3"]);
1175
+ ```
1176
+
1177
+ - Returns an array **positionally aligned** with `keys`.
1178
+ - Missing keys come back as `null` (same as `get`), so the result length always
1179
+ equals the input length — zip them back together by index.
1180
+
1181
+ ```ts
1182
+ const ids = [1, 2, 3];
1183
+ const users = await cache.many(ids.map((id) => `user.${id}`));
1184
+
1185
+ const missingIds = ids.filter((_, index) => users[index] === null);
1186
+ // fetch only the misses from the origin…
1187
+ ```
1188
+
1189
+ ## Write many — `setMany(items, ttl?)`
1190
+
1191
+ ```ts
1192
+ await cache.setMany({
1193
+ "user.1": alice,
1194
+ "user.2": bob,
1195
+ "user.3": carol,
1196
+ }, 3600); // optional TTL (seconds) applied to every entry
1197
+ ```
1198
+
1199
+ - Keys are the object keys; values are the object values.
1200
+ - The optional second arg is a single TTL (seconds) applied to **all** entries —
1201
+ there's no per-entry TTL or `tags` knob here. When you need tags or mixed TTLs,
1202
+ loop with [`cache.set`](@warlock.js/cache/configure-set-options/SKILL.md) and
1203
+ the rich options object instead.
1204
+
1205
+ ## Performance note
1206
+
1207
+ On every driver these run their underlying `get`/`set` calls **concurrently**
1208
+ (`Promise.all`) on the shared connection; on the memory family it's effectively
1209
+ instant. There's no partial-failure handling — if one write rejects, the
1210
+ returned promise rejects.
1211
+
1212
+ ## See also
1213
+
1214
+ - [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md) — single-key `get` / `set` / `remember`
1215
+ - [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — per-entry TTL, tags, conflict policy
1216
+ - [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md) — invalidate a batch by tag
1217
+
1218
+
1219
+ ## use-cache-list `@warlock.js/cache/use-cache-list/SKILL.md`
1220
+
1221
+ ---
1222
+ name: use-cache-list
1223
+ 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`.'
1224
+ ---
1225
+
1226
+ # Lists — the `cache.list<T>(key)` sub-API
1227
+
1228
+ 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.
1229
+
1230
+ ## Shape
1231
+
1232
+ ```ts
1233
+ const recent = cache.list<Event>("recent-events");
1234
+
1235
+ await recent.push(event); // append to tail — returns new length
1236
+ await recent.unshift(priorityEvent); // prepend to head — returns new length
1237
+ const tail = await recent.pop(); // remove + return tail
1238
+ const head = await recent.shift(); // remove + return head
1239
+ const first10 = await recent.slice(0, 10); // view — does not mutate
1240
+ const all = await recent.all();
1241
+ const count = await recent.length();
1242
+ await recent.trim(0, 99); // keep only indices 0..99 inclusive
1243
+ await recent.clear();
1244
+ ```
1245
+
1246
+ ## Type safety
1247
+
1248
+ The generic flows through every method. Pass the element type at the accessor call, not on each method:
1249
+
1250
+ ```ts
1251
+ type Event = { type: string; at: number };
1252
+ const queue = cache.list<Event>("jobs:queue");
1253
+
1254
+ await queue.push({ type: "import", at: Date.now() }); // ✓
1255
+ await queue.push("not an event" as never); // ✗ at the caller
1256
+ ```
1257
+
1258
+ ## Current performance characteristics
1259
+
1260
+ 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.
1261
+
1262
+ 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.
1263
+
1264
+ ## Concurrency warning
1265
+
1266
+ 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.
1267
+
1268
+ Single-process memory with a single writer (typical test / script usage) is fine.
1269
+
1270
+ ## Empty-list cleanup
1271
+
1272
+ 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.
1273
+
1274
+ ```ts
1275
+ await recent.push("a");
1276
+ await recent.pop();
1277
+ await cache.get("recent-events"); // null, not []
1278
+ ```
1279
+
1280
+ ## Typical recipes
1281
+
1282
+ ```ts
1283
+ // Recent-N audit log
1284
+ const audit = cache.list<AuditEntry>("audit:recent");
1285
+ await audit.unshift(entry); // newest at head
1286
+ await audit.trim(0, 999); // keep most-recent 1000
1287
+
1288
+ // Lightweight job queue (single-node)
1289
+ const queue = cache.list<Job>("jobs:pending");
1290
+ await queue.push(job);
1291
+ const next = await queue.shift(); // FIFO
1292
+
1293
+ // Stack
1294
+ const stack = cache.list<Frame>("stack");
1295
+ await stack.push(frame);
1296
+ const top = await stack.pop(); // LIFO
1297
+ ```
1298
+
1299
+ ## What lists are NOT for
1300
+
1301
+ - Unordered uniqueness — no native set today; use a plain object/Map in memory, or roll your own via `cache.get/set`.
1302
+ - Hash / field maps — same; use individual keys with a shared prefix.
1303
+ - Ordered top-N with scoring — no sorted-set analog today.
1304
+
1305
+ These are tracked as candidates for v3.
1306
+
1307
+
1308
+ ## use-cache-lock `@warlock.js/cache/use-cache-lock/SKILL.md`
1309
+
1310
+ ---
1311
+ name: use-cache-lock
1312
+ 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`.'
1313
+ ---
1314
+
1315
+ # `cache.lock()` — distributed locks with auto-release
1316
+
1317
+ `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.
1318
+
1319
+ ## When to use
1320
+
1321
+ - A task should run on only **one server at a time** (cron jobs, imports, migrations).
1322
+ - Idempotent webhook or payment processing — dedup across retries.
1323
+ - Any time you'd otherwise write `try { … } finally { cache.remove(lockKey); }`.
1324
+
1325
+ **Not for memoization** — use [`cached()`](@warlock.js/cache/use-cached-hof/SKILL.md) or `cache.remember()`.
1326
+
1327
+ ## Shape
1328
+
1329
+ ```ts
1330
+ // Primary — positional TTL
1331
+ await cache.lock(key, ttl, fn);
1332
+
1333
+ // With options — owner for debugging, per-call driver override
1334
+ await cache.lock(key, { ttl, owner?, driver? }, fn);
1335
+ ```
1336
+
1337
+ **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.
1338
+
1339
+ 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`.
1340
+
1341
+ ## Lock value and release
1342
+
1343
+ - 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.
1344
+ - 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.
1345
+ - A release error is logged and never replaces `fn`'s result or error.
1346
+
1347
+ ## Return shape — discriminated union
1348
+
1349
+ ```ts
1350
+ type LockOutcome<T> =
1351
+ | { acquired: true; value: T }
1352
+ | { acquired: false };
1353
+ ```
1354
+
1355
+ Unambiguous even when `fn` returns `undefined`. Narrow with TS:
1356
+
1357
+ ```ts
1358
+ const outcome = await cache.lock("lock.x", "1m", async () => compute());
1359
+
1360
+ if (outcome.acquired) {
1361
+ console.log(outcome.value); // typed
1362
+ } else {
1363
+ console.log("someone else is running");
1364
+ }
1365
+ ```
1366
+
1367
+ ## Recipes
1368
+
1369
+ ### Cron on only one server
1370
+
1371
+ ```ts
1372
+ cron.daily("3am", () =>
1373
+ cache.lock("lock.cleanup", "30m", () => db.cleanup()),
1374
+ );
1375
+ ```
1376
+
1377
+ ### Idempotent webhook
1378
+
1379
+ ```ts
1380
+ app.post("/webhooks/stripe", async (req, res) => {
1381
+ const outcome = await cache.lock(
1382
+ `webhook.stripe.${req.body.id}`,
1383
+ "24h",
1384
+ () => processStripeEvent(req.body),
1385
+ );
1386
+
1387
+ if (!outcome.acquired) {
1388
+ return res.status(200).json({ status: "already-processed" });
1389
+ }
1390
+
1391
+ res.status(200).json({ status: "processed" });
1392
+ });
1393
+ ```
1394
+
1395
+ ### Batch job with debug-friendly owner
1396
+
1397
+ ```ts
1398
+ await cache.lock(
1399
+ `lock.report.${date}`,
1400
+ { ttl: "1h", owner: `worker.${process.env.HOSTNAME}` },
1401
+ () => generateReport(date),
1402
+ );
1403
+ ```
1404
+
1405
+ `await cache.get("lock.report.2026-04-24")` reveals which worker holds the lock.
1406
+
1407
+ ## Driver behavior
1408
+
1409
+ | Driver | Cross-process safe? |
1410
+ |--------|:-:|
1411
+ | `redis` | ✅ Native `SET … NX EX`, Lua release |
1412
+ | `pg` | ✅ `INSERT … ON CONFLICT`, one-statement release |
1413
+ | `memory` / `memoryExtended` / `lru` | ❌ In-process only (exclusive within the process) |
1414
+ | `file` | ⚠️ Single-host only (exclusive create) |
1415
+ | `null` | n/a — always "acquires" |
1416
+
1417
+ ## Gotchas
1418
+
1419
+ - **Non-re-entrant in v1.** A recursive call for the same key gets `{ acquired: false }`.
1420
+ - **Don't release inside `fn`.** `lock()` handles release in `finally`. Manual `cache.remove(lockKey)` inside `fn` would let another process jump in mid-work.
1421
+ - **TTL shorter than `fn` runtime = race.** Pick a TTL with generous margin; there is no renewal.
1422
+ - **Cross-server requires `redis` or `pg`.** Memory / LRU / file drivers don't coordinate across servers.
1423
+
1424
+
1425
+ ## use-cache-namespace `@warlock.js/cache/use-cache-namespace/SKILL.md`
1426
+
1427
+ ---
1428
+ name: use-cache-namespace
1429
+ 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.'
1430
+ ---
1431
+
1432
+ # Scoped caches — `cache.namespace(prefix, options?)`
1433
+
1434
+ 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.
1435
+
1436
+ ## Shape
1437
+
1438
+ ```ts
1439
+ const chat = cache.namespace(`chats.${id}`, { ttl: "30d", tags: [`user.${userId}`] });
1440
+
1441
+ await chat.set("messages.10", msg); // chats.<id>.messages.10, 30d, user.<userId>
1442
+ await chat.set("draft", d, { ttl: "1h" }); // per-call ttl wins
1443
+ await chat.tags(["unread"]).set("ping", p); // tags merge: user.<userId> + unread
1444
+ await chat.clear(); // sugar for removeNamespace
1445
+ ```
1446
+
1447
+ Scopes are pure views — same connection, same driver, no extra state. Per-call options always win over scope defaults; tags merge additively.
1448
+
1449
+ ## Nested scopes
1450
+
1451
+ ```ts
1452
+ const chat = cache.namespace(`chats.${id}`, { ttl: "30d" });
1453
+ const typing = chat.namespace("typing", { ttl: "5s" }); // overrides parent ttl
1454
+
1455
+ await typing.set("user.42", true); // chats.<id>.typing.user.42, 5s
1456
+ ```
1457
+
1458
+ Nested scopes inherit defaults and can override. Tags accumulate.
1459
+
1460
+ ## When to reach for it
1461
+
1462
+ - The prefix repeats more than 2–3 times.
1463
+ - A whole prefix shares a TTL or tag policy.
1464
+ - You want `.clear()` to read like the intent ("clear this chat") instead of `removeNamespace(...)` boilerplate.
1465
+
1466
+ Inline prefixes are still fine for one-off writes.
1467
+
1468
+ ## Plain `removeNamespace` when you already have the prefix
1469
+
1470
+ When you *do* know the prefix string and don't need a scoped handle for repeated reads/writes:
1471
+
1472
+ ```ts
1473
+ await cache.set("user:1:profile", profile);
1474
+ await cache.set("user:1:prefs", prefs);
1475
+ await cache.set("user:2:profile", otherProfile);
1476
+
1477
+ await cache.removeNamespace("user.1"); // drops both user:1 entries, keeps user:2
1478
+ ```
1479
+
1480
+ 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.
1481
+
1482
+ ## Multi-tenant scoping at the driver level
1483
+
1484
+ 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.
1485
+
1486
+ ```ts
1487
+ options: {
1488
+ redis: {
1489
+ url: "...",
1490
+ globalPrefix: () => `tenant-${currentContext.tenantId}`,
1491
+ },
1492
+ }
1493
+ ```
1494
+
1495
+ ## SWR + namespace
1496
+
1497
+ ```ts
1498
+ const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
1499
+
1500
+ await feed.swr(
1501
+ "home",
1502
+ { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
1503
+ () => buildHomeFeed(userId),
1504
+ );
1505
+ // stored at feed.<userId>.home, tagged [user.<userId>, computed]
1506
+ ```
1507
+
1508
+ 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).
1509
+
1510
+ ## Things NOT to do
1511
+
1512
+ - 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.
1513
+ - Don't expect `cache.namespace(prefix).clear()` to do anything on the `null` driver — `removeNamespace` no-ops there (it caches nothing).
1514
+ - Don't mix prefix separators. The convention is `.` (dot) — pick one and stick with it across scopes so nested prefixes compose predictably.
1515
+
1516
+
1517
+ ## use-cache-similarity `@warlock.js/cache/use-cache-similarity/SKILL.md`
1518
+
1519
+ ---
1520
+ name: use-cache-similarity
1521
+ 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`.'
1522
+ ---
1523
+
1524
+ # `cache.similar()` — vector-based retrieval
1525
+
1526
+ `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.
1527
+
1528
+ ## Shape
1529
+
1530
+ ```ts
1531
+ // Index on the way in.
1532
+ await cache.set(key, value, { vector: number[], tags?, ttl? });
1533
+
1534
+ // Query.
1535
+ const hits = await cache.similar<T>(queryVec, {
1536
+ topK: number, // required
1537
+ threshold?: number, // [0, 1]; hits below are dropped
1538
+ tags?: string[], // narrow candidate pool by tag (union)
1539
+ });
1540
+ // hits: { key: string; value: T; score: number }[] // score in [0, 1]
1541
+ ```
1542
+
1543
+ ## Capability matrix
1544
+
1545
+ | Driver | `similar()` |
1546
+ |---|---|
1547
+ | `memory` / `memoryExtended` / `lru` | ✅ Brute force (O(N)) — dev only past ~10k entries |
1548
+ | `pg` *with* `options.pg.vector` | ✅ pgvector + HNSW/IVFFlat index |
1549
+ | `pg` *without* `options.pg.vector` | ❌ Throws `CacheUnsupportedError` |
1550
+ | `redis` | ❌ Throws (RediSearch on backlog) |
1551
+ | `file` | ❌ Throws |
1552
+ | `null` | Returns `[]` |
1553
+
1554
+ ## Always-true facts
1555
+
1556
+ 1. **Cache is embedding-agnostic.** Caller computes vectors. The cache stores and ranks; it doesn't call out to an embedder.
1557
+ 2. **Only entries written with `set({ vector })` show up.** A plain `set` adds the entry as KV — invisible to `similar()`.
1558
+ 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.
1559
+ 4. **Tag filter narrows the candidate pool *before* ranking** — union semantics (entry must carry at least one of the listed tags).
1560
+ 5. **Dimension mismatch throws `CacheConfigurationError`** at both `set({ vector })` and `similar()` time. Don't switch embedders without re-indexing.
1561
+ 6. **TTL + LRU eviction also drop the vector** — expired or evicted entries are invisible to `similar()`.
1562
+
1563
+ ## Recipes
1564
+
1565
+ ### Semantic cache for an LLM
1566
+
1567
+ ```ts
1568
+ const queryVec = await embed(prompt);
1569
+ const hits = await cache.similar<Answer>(queryVec, { topK: 1, threshold: 0.92 });
1570
+
1571
+ if (hits.length > 0) {
1572
+ return hits[0].value; // skip the LLM call
1573
+ }
1574
+
1575
+ const answer = await llm.complete(prompt);
1576
+ await cache.set(`q.${hash(prompt)}`, answer, {
1577
+ vector: queryVec,
1578
+ ttl: "30d",
1579
+ tags: ["llm-cache"],
1580
+ });
1581
+ return answer;
1582
+ ```
1583
+
1584
+ ### Tag-narrowed RAG
1585
+
1586
+ ```ts
1587
+ const hits = await cache.similar<Doc>(await embed(question), {
1588
+ topK: 5,
1589
+ threshold: 0.7,
1590
+ tags: ["docs", `tenant.${tenantId}`],
1591
+ });
1592
+ ```
1593
+
1594
+ ### Production swap — same code, different driver
1595
+
1596
+ ```ts
1597
+ // Dev:
1598
+ options: { memory: { ttl: "1h" } }
1599
+
1600
+ // Prod — same set/similar calls; index now lives in pgvector:
1601
+ options: { pg: { client: pool, vector: { dimensions: 1536 } } }
1602
+ ```
1603
+
1604
+ See [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md).
1605
+
1606
+ ## Things NOT to do
1607
+
1608
+ - 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.
1609
+ - Don't pass an empty array as `vector` — `cosineSimilarity` throws `CacheConfigurationError`.
1610
+ - Don't mix vector dimensions in the same driver — re-embed when models change.
1611
+ - Don't expect `similar()` to surface a missing vector (`set` without the `vector` option). Plain KV entries stay out of the similarity index.
1612
+ - Don't use `topK: 0` or negative — `pg` rejects with `CacheConfigurationError`; memory drivers return `[]` but it's a code smell.
1613
+
1614
+
1615
+ ## use-cache-tags `@warlock.js/cache/use-cache-tags/SKILL.md`
1616
+
1617
+ ---
1618
+ name: use-cache-tags
1619
+ 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`.'
1620
+ ---
1621
+
1622
+ # Tag-based invalidation
1623
+
1624
+ 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.
1625
+
1626
+ ## Attach tags on write
1627
+
1628
+ ```ts
1629
+ // Inline — terser when you know the tags up front
1630
+ await cache.set("user:1:profile", profile, { tags: ["users", "tenant-42"] });
1631
+ await cache.set("user:1:prefs", prefs, { tags: ["users", "tenant-42"] });
1632
+
1633
+ // Fluent — useful when you already have a tagged handle
1634
+ const users = cache.tags(["users"]);
1635
+ await users.set("user:1", user);
1636
+ await users.set("user:2", otherUser);
1637
+ ```
1638
+
1639
+ ## Invalidate
1640
+
1641
+ ```ts
1642
+ // Drop everything tagged "users"
1643
+ await cache.tags(["users"]).invalidate();
1644
+
1645
+ // Multi-tag — matches either tag (union)
1646
+ await cache.tags(["tenant-42"]).invalidate();
1647
+ ```
1648
+
1649
+ Multi-tag is **union** semantics: an entry is invalidated if it carries **at least one** of the listed tags.
1650
+
1651
+ ## When to reach for tags vs namespaces
1652
+
1653
+ | Use case | Reach for |
1654
+ | --- | --- |
1655
+ | The keys share a known prefix | `cache.removeNamespace("prefix")` ([`use-cache-namespace`](@warlock.js/cache/use-cache-namespace/SKILL.md)) |
1656
+ | The keys are spread across prefixes, tied by entity | Tags |
1657
+ | Both apply | Tags — more flexible; cheap on most drivers |
1658
+
1659
+ Namespaces are cheaper (no reverse index). Tags are more powerful (any key can carry any tag).
1660
+
1661
+ ## Inline tag semantics
1662
+
1663
+ 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.
1664
+
1665
+ ## Driver behavior
1666
+
1667
+ | Driver | Tag invalidation |
1668
+ | --- | :-: |
1669
+ | `null` | noop |
1670
+ | `memory` / `memoryExtended` / `lru` | ✓ (separate index store; eviction and expiry never touch it) |
1671
+ | `file` | ✓ (serialized index) |
1672
+ | `redis` | ✓ (native SET inside the prefix) |
1673
+ | `pg` | ✓ (JSON-array merge on the index row) |
1673
1674
 
1674
1675
  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.
1675
1676
 
@@ -1705,227 +1706,227 @@ See [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-h
1705
1706
 
1706
1707
  ## use-cache-update-merge `@warlock.js/cache/use-cache-update-merge/SKILL.md`
1707
1708
 
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()` |
1709
+ ---
1710
+ name: use-cache-update-merge
1711
+ 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`.'
1712
+ ---
1713
+
1714
+ # `update` and `merge` — atomic read-modify-write
1715
+
1716
+ 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.
1717
+
1718
+ ## `update(key, fn, options?)`
1719
+
1720
+ Read the current value, pass it to `fn`, write what `fn` returns.
1721
+
1722
+ ```ts
1723
+ // Counter increment
1724
+ await cache.update<number>("views", (current) => (current ?? 0) + 1);
1725
+
1726
+ // Update nested state with defaults
1727
+ await cache.update<UserState>("user:1:state", (current) => ({
1728
+ ...(current ?? defaultState),
1729
+ lastSeenAt: Date.now(),
1730
+ }));
1731
+
1732
+ // Conditional update — return null to remove
1733
+ await cache.update<Session>("session:abc", (current) => {
1734
+ if (!current || current.expired) {
1735
+ return null; // removes the key
1736
+ }
1737
+ return { ...current, extendedAt: Date.now() };
1738
+ });
1739
+ ```
1740
+
1741
+ - `fn` receives `current: T | null`. Missing keys are `null`, not an exception.
1742
+ - Returning `null` **removes** the entry.
1743
+ - TTL is preserved by default. To reset, pass `{ ttl: "1h" }` as the 3rd arg.
1744
+
1745
+ ## `merge(key, partial, options?)`
1746
+
1747
+ Shallow-merge sugar for the common "update one field" shape:
1748
+
1749
+ ```ts
1750
+ await cache.merge<User>("user:1", { name: "Jane" });
1751
+ await cache.merge<User>("user:1", { lastSeenAt: Date.now() }, { ttl: "1h" });
1752
+ ```
1753
+
1754
+ - **Shallow only.** Arrays are replaced wholesale. Nested objects overwrite.
1755
+ - Missing key → treats current as `{}`, creates with the partial.
1756
+ - Preserves existing TTL unless the options override is passed.
1757
+
1758
+ 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))`.
1759
+
1760
+ ## What you can't do
1761
+
1762
+ - No JSONPath / dot-path partial updates (`update(key, "profile.name", "Jane")`). Use the callback form.
1763
+ - No file-driver support — both methods throw `CacheUnsupportedError` there. Use memory or redis.
1764
+ - 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.
1765
+
1766
+ ## Concurrent-in-process correctness
1767
+
1768
+ ```ts
1769
+ await cache.set("counter", 0);
1770
+
1771
+ // 10 concurrent increments, all on the same key — all serialize
1772
+ await Promise.all(
1773
+ Array.from({ length: 10 }, () =>
1774
+ cache.update<number>("counter", (current) => (current ?? 0) + 1),
1775
+ ),
1776
+ );
1777
+
1778
+ await cache.get("counter"); // 10 — not a lost-update
1779
+ ```
1780
+
1781
+ The per-key lock map lives on the driver instance and is cleared after each chain link finishes. No leak.
1782
+
1783
+ ## When to reach for what
1784
+
1785
+ | Task | Use |
1786
+ | --- | --- |
1787
+ | "Add 1 to this counter" | `increment()` (Redis-atomic via `INCRBY`; in-process elsewhere) |
1788
+ | "Change one field on a cached object" | `merge()` |
1789
+ | "Read-decide-maybe-write, possibly remove" | `update()` |
1790
+ | "Only set if missing" | `set(k, v, { onConflict: "create" })` |
1791
+ | "Only set if already exists" | `set(k, v, { onConflict: "update" })` |
1792
+ | "Read-then-delete atomically" | `pull()` |
1792
1793
 
1793
1794
 
1794
1795
  ## use-cache-utils `@warlock.js/cache/use-cache-utils/SKILL.md`
1795
1796
 
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
1797
+ ---
1798
+ name: use-cache-utils
1799
+ 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`.'
1800
+ ---
1801
+
1802
+ # Cache utilities
1803
+
1804
+ The helpers the drivers are built from, all re-exported from
1805
+ `@warlock.js/cache`. You rarely need them at the call site — `cache.set("k", v,
1806
+ "1h")` parses the duration for you. They earn their keep when you write a custom
1807
+ driver or do cache-adjacent work outside the manager.
1808
+
1809
+ ## TTL helpers
1810
+
1811
+ ```ts
1812
+ import { parseTtl, expiresAtToTtl, resolveTtl } from "@warlock.js/cache";
1813
+
1814
+ parseTtl(3600); // 3600
1815
+ parseTtl("1h"); // 3600 (duration string via `ms`)
1816
+ parseTtl(Infinity); // Infinity (no expiry)
1817
+ parseTtl(-5); // throws CacheConfigurationError
1818
+
1819
+ expiresAtToTtl(new Date(Date.now() + 60_000)); // ~60 (absolute → relative seconds)
1820
+ expiresAtToTtl(Date.now() - 1000); // throws — deadline in the past
1821
+
1822
+ // caller ttl > expiresAt > fallback; ttl+expiresAt together throws
1823
+ resolveTtl("1h", undefined, Infinity); // 3600
1824
+ resolveTtl(undefined, undefined, 1800); // 1800 (fallback)
1825
+ ```
1826
+
1827
+ ## Option normalizers
1828
+
1829
+ Coerce the polymorphic 2nd/3rd argument of `set`/`remember` into a uniform shape
1830
+ — what `BaseCacheDriver` uses internally:
1831
+
1832
+ ```ts
1833
+ import { normalizeToOptions, normalizeToRememberOptions } from "@warlock.js/cache";
1834
+
1835
+ normalizeToOptions(60); // { ttl: 60 }
1836
+ normalizeToOptions("1h"); // { ttl: "1h" }
1837
+ normalizeToOptions({ tags: ["x"] }); // returned as-is
1838
+ normalizeToRememberOptions("1h"); // { ttl: "1h" } (no expiresAt/onConflict)
1839
+ ```
1840
+
1841
+ ## Key + tag helpers
1842
+
1843
+ ```ts
1844
+ import { parseCacheKey, mergeTagSets, injectTags } from "@warlock.js/cache";
1845
+
1846
+ parseCacheKey("users:1"); // "users.1"
1847
+ parseCacheKey({ page: 1, q: "John" }); // "page.1.q.John"
1848
+ parseCacheKey("user:1", { globalPrefix: "app" }); // "app.user.1"
1849
+
1850
+ mergeTagSets(["a", "b"], ["b", "c"]); // ["a","b","c"] (deduped union)
1851
+ mergeTagSets(undefined, undefined); // undefined
1852
+
1853
+ injectTags({ ttl: "1h" }, ["unread"]); // { ttl: "1h", tags: ["unread"] } (pure, no mutation)
1854
+ ```
1855
+
1856
+ ## Vector scoring
1857
+
1858
+ ```ts
1859
+ import { cosineSimilarity } from "@warlock.js/cache";
1860
+
1861
+ cosineSimilarity([1, 0, 0], [1, 0, 0]); // 1
1862
+ cosineSimilarity([1, 0, 0], [0, 1, 0]); // 0
1863
+ cosineSimilarity([1, 2, 3], [1, 2]); // throws — dimension mismatch
1864
+ ```
1865
+
1866
+ Powers the brute-force `cache.similar()` on the memory drivers — reach for it
1867
+ directly only when scoring vectors outside the cache.
1868
+
1869
+ ## TTL constants — `CACHE_FOR`
1870
+
1871
+ ```ts
1872
+ import { cache, CACHE_FOR } from "@warlock.js/cache";
1873
+
1874
+ await cache.set("report", data, CACHE_FOR.ONE_WEEK);
1875
+ ```
1876
+
1877
+ Members: `HALF_HOUR`, `ONE_HOUR`, `HALF_DAY`, `ONE_DAY`, `ONE_WEEK`,
1878
+ `HALF_MONTH`, `ONE_MONTH`, `TWO_MONTHS`, `SIX_MONTHS`, `ONE_YEAR` (all seconds).
1879
+ For most call sites the duration string (`"1h"`, `"7d"`) reads better.
1880
+
1881
+ ## See also
1882
+
1883
+ - [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — the high-level `set` options these normalize
1884
+ - [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — `cache.similar()`, which uses `cosineSimilarity`
1885
+ - [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — building a custom driver where these help
1885
1886
 
1886
1887
 
1887
1888
  ## use-cached-hof `@warlock.js/cache/use-cached-hof/SKILL.md`
1888
1889
 
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
-
1890
+ ---
1891
+ name: use-cached-hof
1892
+ 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`.'
1893
+ ---
1894
+
1895
+ # `cached()` — function-memoization wrapper
1896
+
1897
+ `cached()` turns any async function into a memoized version. One declaration, many call sites, with a bound `.invalidate()` helper.
1898
+
1899
+ ## When to use
1900
+
1901
+ Reach for `cached()` instead of `cache.remember()` when:
1902
+ - You have a function you'll call from many places.
1903
+ - You want the caching strategy declared once, not repeated at every call site.
1904
+ - You want `.invalidate()` available without manually deriving keys.
1905
+
1906
+ Stick with `cache.remember()` for one-shot "get-or-compute" calls.
1907
+
1908
+ ## The three shapes — `fn` always first
1909
+
1910
+ ```ts
1911
+ import { cached } from "@warlock.js/cache";
1912
+
1913
+ // 1. Prefix shorthand — driver default TTL; key auto-derived from args
1914
+ cached(fn, "user");
1915
+
1916
+ // 2. Prefix + TTL
1917
+ cached(fn, "user", "1h");
1918
+
1919
+ // 3. Options form — custom key fn, tags, per-call driver
1920
+ cached(fn, {
1921
+ key: (id: number) => `user.${id}`,
1922
+ ttl: "1h",
1923
+ tags: ["users"],
1924
+ driver: "redis",
1925
+ });
1926
+ ```
1927
+
1928
+ ## Auto-key rules (shorthand only)
1929
+
1929
1930
  | Args | Key |
1930
1931
  |------|-----|
1931
1932
  | None | `prefix` |
@@ -1936,166 +1937,166 @@ The hash keeps distinct args on distinct keys (`("1.private", "")` ≠ `("1", "p
1936
1937
 
1937
1938
  **Upgrade (5.20):** the key format changed from dot-joined args, so old entries are never read and every call misses once.
1938
1939
 
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.
1940
+ ## Return shape
1941
+
1942
+ ```ts
1943
+ type CachedFn<Args, R> = ((...args: Args) => Promise<R>) & {
1944
+ invalidate(...args: Args): Promise<void>;
1945
+ };
1946
+ ```
1947
+
1948
+ `.refresh()` and `.peek()` are deferred to v2.1 — file demand in `backlog.md` if you need them.
1949
+
1950
+ ## Recipes
1951
+
1952
+ ### Cached DB lookup with write-side invalidation
1953
+
1954
+ ```ts
1955
+ const getUser = cached((id: number) => db.users.find(id), "user", "1h");
1956
+
1957
+ // On update
1958
+ await db.users.update(42, patch);
1959
+ await getUser.invalidate(42);
1960
+ ```
1961
+
1962
+ ### Tag-based bulk invalidation across wrappers
1963
+
1964
+ ```ts
1965
+ const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
1966
+ const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
1967
+
1968
+ await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
1969
+ ```
1970
+
1971
+ See [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md).
1972
+
1973
+ ### Project a subset of args into the key
1974
+
1975
+ ```ts
1976
+ const getCategoryMeta = cached(
1977
+ (filters: Filters) => db.categories.meta(filters.category),
1978
+ { key: (f) => `category.meta.${f.category}`, ttl: "1h" }, // ignores `sort`, `page`
1979
+ );
1980
+ ```
1981
+
1982
+ ## Interaction with the rest of the API
1983
+
1984
+ - Uses `cache.remember()` internally → inherits stampede protection within a single Node process.
1985
+ - Forwards `tags` and `driver` through the (extended) `RememberOptions` shape on `remember`.
1986
+ - `.invalidate()` calls `cache.remove()` — no side effects beyond the single entry.
1987
+
1988
+ ## Things NOT to do
1989
+
1990
+ - 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.
1991
+ - Don't rely on cross-process stampede safety. `cached` inherits `remember`'s in-process lock; cross-process needs a distributed lock via `onConflict: "create"`.
1992
+ - Don't include secrets in args — they'd land in cache keys. Project only the identifying fields into the key.
1992
1993
 
1993
1994
 
1994
1995
  ## use-swr `@warlock.js/cache/use-swr/SKILL.md`
1995
1996
 
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.
1997
+ ---
1998
+ name: use-swr
1999
+ 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).'
2000
+ ---
2001
+
2002
+ # Stale-while-revalidate — `cache.swr(key, options, fn)`
2003
+
2004
+ 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.
2005
+
2006
+ ## When to reach for it
2007
+
2008
+ 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.
2009
+
2010
+ 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).
2011
+
2012
+ ## Three windows
2013
+
2014
+ ```
2015
+ write freshTtl staleTtl
2016
+ │ │ │
2017
+ ▼ ▼ ▼
2018
+ ─────┬──── fresh ─────────┬──── stale ─────────┬──── expired ──→
2019
+ │ return cached │ return cached + │ block, refetch
2020
+ │ no upstream call │ bg refresh │ like a miss
2021
+ ```
2022
+
2023
+ | Window | Behavior |
2024
+ |---|---|
2025
+ | `now < freshTtl` | Return cached. No upstream call. |
2026
+ | `freshTtl ≤ now < staleTtl` | Return cached immediately. Run `fn()` in background; next read sees the refreshed value. |
2027
+ | `now ≥ staleTtl` | Block on `fn()`. Same as `remember()`. |
2028
+
2029
+ ## API
2030
+
2031
+ ```ts
2032
+ await cache.swr(
2033
+ "product.42",
2034
+ {
2035
+ freshTtl: "1m", // CacheTtl — within this, no upstream call
2036
+ staleTtl: "1h", // CacheTtl — past this, block-and-refetch
2037
+ tags?: string[], // applied on first miss + every successful refresh
2038
+ driver?: string, // per-call driver override, like remember()
2039
+ },
2040
+ () => db.products.find(42),
2041
+ );
2042
+ ```
2043
+
2044
+ `staleTtl` MUST be greater than `freshTtl` — otherwise throws.
2045
+
2046
+ ## Key invariants
2047
+
2048
+ 1. **Concurrent stale-window callers share one refresh.** Per-key dedupe via the driver's existing locks map — no thundering herd on background refresh.
2049
+ 2. **Failed background refreshes preserve the stale entry.** No retry storm; the next stale-window read tries again. Failures emit `error` events for observability.
2050
+ 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).
2051
+ 4. **Tags compose.** Per-call tags + scope tags (when via `cache.namespace().swr(...)`) merge additively.
2052
+ 5. **Scope `ttl` defaults are NOT applied to SWR.** `freshTtl` / `staleTtl` always come from the call site.
2053
+
2054
+ ## Driver support
2055
+
2056
+ | Driver | Background refresh |
2057
+ |---|---|
2058
+ | memory / memoryExtended / lru / file / mock | ✅ Full |
2059
+ | redis | ✅ Full (sidecar key for staleAt — backwards-compatible) |
2060
+ | pg | ✅ Full (`stale_at TIMESTAMPTZ` column — provision via `driver.schema()`) |
2061
+ | null | ❌ Always-fetch (null caches nothing) |
2062
+
2063
+ ## Common shapes
2064
+
2065
+ ```ts
2066
+ // Product detail — slightly stale OK, never want to block on DB
2067
+ await cache.swr(`product.${id}`, { freshTtl: "1m", staleTtl: "1h" }, () =>
2068
+ db.products.findById(id),
2069
+ );
2070
+
2071
+ // Dashboard — expensive aggregation, OK to be 5min stale
2072
+ await cache.swr(`dashboard.${tenantId}`, { freshTtl: "5m", staleTtl: "1h" }, () =>
2073
+ computeKPIs(tenantId),
2074
+ );
2075
+
2076
+ // Third-party API — degrade gracefully when upstream is down
2077
+ await cache.swr("exchange.rates", { freshTtl: "10m", staleTtl: "24h" }, () =>
2078
+ fetchFromForexAPI(),
2079
+ );
2080
+ ```
2081
+
2082
+ ## Through scoped caches
2083
+
2084
+ ```ts
2085
+ const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
2086
+
2087
+ await feed.swr(
2088
+ "home",
2089
+ { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
2090
+ () => buildHomeFeed(userId),
2091
+ );
2092
+ // stored at feed.<userId>.home, tagged [user.<userId>, computed]
2093
+ ```
2094
+
2095
+ ## Things NOT to do
2096
+
2097
+ - 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.
2098
+ - 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.
2099
+ - Don't ignore `error` events. A persistent stream of refresh failures means upstream is broken and the cache is masking it.
2100
+ - Don't reach for SWR on the null driver — it caches nothing, so SWR always blocks.
2100
2101
 
2101
2102