@warlock.js/cache 5.30.0 → 5.31.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 (77) hide show
  1. package/CHANGELOG.md +20 -8
  2. package/README.md +12 -21
  3. package/cjs/index.cjs +46 -27
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/cache-manager.d.mts +3 -1
  6. package/esm/cache-manager.d.mts.map +1 -1
  7. package/esm/cache-manager.mjs +2 -2
  8. package/esm/cache-manager.mjs.map +1 -1
  9. package/esm/drivers/base-cache-driver.d.mts +3 -1
  10. package/esm/drivers/base-cache-driver.d.mts.map +1 -1
  11. package/esm/drivers/base-cache-driver.mjs +4 -3
  12. package/esm/drivers/base-cache-driver.mjs.map +1 -1
  13. package/esm/drivers/file-cache-driver.d.mts.map +1 -1
  14. package/esm/drivers/file-cache-driver.mjs.map +1 -1
  15. package/esm/drivers/lru-memory-cache-driver.d.mts +3 -1
  16. package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -1
  17. package/esm/drivers/lru-memory-cache-driver.mjs +6 -4
  18. package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -1
  19. package/esm/drivers/memory-cache-driver.d.mts +3 -1
  20. package/esm/drivers/memory-cache-driver.d.mts.map +1 -1
  21. package/esm/drivers/memory-cache-driver.mjs +9 -3
  22. package/esm/drivers/memory-cache-driver.mjs.map +1 -1
  23. package/esm/drivers/mock-cache-driver.d.mts +3 -1
  24. package/esm/drivers/mock-cache-driver.d.mts.map +1 -1
  25. package/esm/drivers/mock-cache-driver.mjs +11 -4
  26. package/esm/drivers/mock-cache-driver.mjs.map +1 -1
  27. package/esm/drivers/pg-cache-driver.d.mts +3 -1
  28. package/esm/drivers/pg-cache-driver.d.mts.map +1 -1
  29. package/esm/drivers/pg-cache-driver.mjs +6 -3
  30. package/esm/drivers/pg-cache-driver.mjs.map +1 -1
  31. package/esm/drivers/redis-cache-driver.d.mts +4 -2
  32. package/esm/drivers/redis-cache-driver.d.mts.map +1 -1
  33. package/esm/drivers/redis-cache-driver.mjs +4 -3
  34. package/esm/drivers/redis-cache-driver.mjs.map +1 -1
  35. package/esm/list/memory-cache-list.mjs.map +1 -1
  36. package/esm/metrics.d.mts.map +1 -1
  37. package/esm/metrics.mjs.map +1 -1
  38. package/esm/scoped-cache.d.mts +3 -1
  39. package/esm/scoped-cache.d.mts.map +1 -1
  40. package/esm/scoped-cache.mjs +2 -2
  41. package/esm/scoped-cache.mjs.map +1 -1
  42. package/esm/tagged-cache.d.mts +3 -1
  43. package/esm/tagged-cache.d.mts.map +1 -1
  44. package/esm/tagged-cache.mjs +2 -2
  45. package/esm/tagged-cache.mjs.map +1 -1
  46. package/esm/tagged-scoped-cache.d.mts +3 -1
  47. package/esm/tagged-scoped-cache.d.mts.map +1 -1
  48. package/esm/tagged-scoped-cache.mjs +2 -2
  49. package/esm/tagged-scoped-cache.mjs.map +1 -1
  50. package/esm/types.d.mts +16 -5
  51. package/esm/types.d.mts.map +1 -1
  52. package/esm/types.mjs.map +1 -1
  53. package/esm/utils.d.mts.map +1 -1
  54. package/esm/utils.mjs.map +1 -1
  55. package/llms-full.txt +229 -224
  56. package/llms.txt +1 -1
  57. package/package.json +3 -3
  58. package/skills/apply-cache-patterns/SKILL.md +3 -2
  59. package/skills/cache-basics/SKILL.md +24 -24
  60. package/skills/configure-pg-cache/SKILL.md +2 -2
  61. package/skills/configure-set-options/SKILL.md +14 -14
  62. package/skills/handle-cache-errors/SKILL.md +8 -8
  63. package/skills/index.md +4 -1
  64. package/skills/observe-cache/SKILL.md +8 -8
  65. package/skills/pick-cache-driver/SKILL.md +24 -23
  66. package/skills/test-cache-code/SKILL.md +15 -10
  67. package/skills/use-cache-atomic/SKILL.md +17 -11
  68. package/skills/use-cache-bulk/SKILL.md +8 -5
  69. package/skills/use-cache-list/SKILL.md +13 -13
  70. package/skills/use-cache-lock/SKILL.md +11 -17
  71. package/skills/use-cache-namespace/SKILL.md +11 -13
  72. package/skills/use-cache-similarity/SKILL.md +9 -9
  73. package/skills/use-cache-tags/SKILL.md +13 -13
  74. package/skills/use-cache-update-merge/SKILL.md +10 -10
  75. package/skills/use-cache-utils/SKILL.md +17 -17
  76. package/skills/use-cached-hof/SKILL.md +8 -7
  77. package/skills/use-swr/SKILL.md +14 -18
package/llms-full.txt CHANGED
@@ -19,7 +19,7 @@ Common shapes — the "general patterns" file. Specialized shapes live in their
19
19
 
20
20
  ```ts
21
21
  const user = await cache.remember(`user:${id}`, "1h", async () => {
22
- return db.users.find(id); // runs only on cache miss
22
+ return db.users.find(id); // runs only on cache miss
23
23
  });
24
24
  ```
25
25
 
@@ -89,8 +89,9 @@ await cache.set("user:1", user, "1h");
89
89
  ```
90
90
 
91
91
  Clear a tenant out:
92
+
92
93
  ```ts
93
- await cache.removeNamespace(""); // when globalPrefix is set, flush scopes to it
94
+ await cache.removeNamespace(""); // when globalPrefix is set, flush scopes to it
94
95
  // or
95
96
  await cache.tags([`tenant-${tenantId}`]).invalidate();
96
97
  ```
@@ -178,15 +179,15 @@ await cache.init();
178
179
  ```ts
179
180
  // Set + get
180
181
  await cache.set("user.1", user, "1h");
181
- const cached = await cache.get<User>("user.1"); // User | null
182
+ const cached = await cache.get<User>("user.1"); // User | null
182
183
 
183
184
  // Presence + read-and-delete
184
- const exists = await cache.has("user.1"); // boolean
185
- const taken = await cache.pull<User>("user.1"); // returns then removes
185
+ const exists = await cache.has("user.1"); // boolean
186
+ const taken = await cache.pull<User>("user.1"); // returns then removes
186
187
 
187
188
  // Remove + flush
188
189
  await cache.remove("user.1");
189
- await cache.flush(); // wipe everything (current driver)
190
+ await cache.flush(); // wipe everything (current driver)
190
191
 
191
192
  // Many at once — array positionally aligned with the keys (null for misses)
192
193
  const [u1, u2, u3] = await cache.many(["user.1", "user.2", "user.3"]);
@@ -196,9 +197,9 @@ const [u1, u2, u3] = await cache.many(["user.1", "user.2", "user.3"]);
196
197
  await cache.forever("config.version", "1.2.3");
197
198
 
198
199
  // Counters
199
- await cache.increment("post.42.views"); // +1, returns new value
200
- await cache.increment("post.42.views", 10); // +10
201
- await cache.decrement("inventory.sku-x"); // -1
200
+ await cache.increment("post.42.views"); // +1, returns new value
201
+ await cache.increment("post.42.views", 10); // +10
202
+ await cache.decrement("inventory.sku-x"); // -1
202
203
 
203
204
  // Memoize an expensive function
204
205
  const user = await cache.remember("user.1", "1h", async () => db.users.find(1));
@@ -206,23 +207,23 @@ const user = await cache.remember("user.1", "1h", async () => db.users.find(1));
206
207
 
207
208
  ## Pick a skill
208
209
 
209
- | If the task is about… | Load |
210
- | --- | --- |
211
- | Choosing a driver, configuring it, or understanding what each one does best | The `pick-cache-driver` topic |
212
- | The `set` options object (`ttl`, `expiresAt`, `tags`, `onConflict`, `driver`, `vector`) | The `configure-set-options` topic |
213
- | Memoization with `remember()`, counters, negative caching, per-tenant scoping, TTL constants | The `apply-cache-patterns` topic |
214
- | Scoped handles via `cache.namespace(prefix, options?)` | The `use-cache-namespace` topic |
215
- | Tag-based invalidation — `cache.tags([...]).invalidate()` | The `use-cache-tags` topic |
216
- | Stale-while-revalidate — `cache.swr(...)` | The `use-swr` topic |
217
- | Wrapping a function with `cached()` — HOF memoization with `.invalidate()` | The `use-cached-hof` topic |
218
- | Distributed locks — `cache.lock(key, ttl, fn)` with auto-release | The `use-cache-lock` topic |
219
- | Queues, recent-N buffers, `push`/`shift`/`trim` — the list sub-API | The `use-cache-list` topic |
220
- | Atomic read-modify-write via `update()` and `merge()` | The `use-cache-update-merge` topic |
221
- | Similarity retrieval — `set({ vector })` + `cache.similar(...)` | The `use-cache-similarity` topic |
222
- | Postgres driver setup (KV-only or with pgvector) | The `configure-pg-cache` topic |
223
- | `cache.metrics()` aggregate snapshot + event bus for per-event reactions | The `observe-cache` topic |
224
- | Error classes (`CacheConfigurationError`, `CacheUnsupportedError`, etc.) | The `handle-cache-errors` topic |
225
- | Tests that touch cache code paths — `MockCacheDriver`, `MemoryCacheDriver` | The `test-cache-code` topic |
210
+ | If the task is about… | Load |
211
+ | -------------------------------------------------------------------------------------------- | ---------------------------------- |
212
+ | Choosing a driver, configuring it, or understanding what each one does best | The `pick-cache-driver` topic |
213
+ | The `set` options object (`ttl`, `expiresAt`, `tags`, `onConflict`, `driver`, `vector`) | The `configure-set-options` topic |
214
+ | Memoization with `remember()`, counters, negative caching, per-tenant scoping, TTL constants | The `apply-cache-patterns` topic |
215
+ | Scoped handles via `cache.namespace(prefix, options?)` | The `use-cache-namespace` topic |
216
+ | Tag-based invalidation — `cache.tags([...]).invalidate()` | The `use-cache-tags` topic |
217
+ | Stale-while-revalidate — `cache.swr(...)` | The `use-swr` topic |
218
+ | Wrapping a function with `cached()` — HOF memoization with `.invalidate()` | The `use-cached-hof` topic |
219
+ | Distributed locks — `cache.lock(key, ttl, fn)` with auto-release | The `use-cache-lock` topic |
220
+ | Queues, recent-N buffers, `push`/`shift`/`trim` — the list sub-API | The `use-cache-list` topic |
221
+ | Atomic read-modify-write via `update()` and `merge()` | The `use-cache-update-merge` topic |
222
+ | Similarity retrieval — `set({ vector })` + `cache.similar(...)` | The `use-cache-similarity` topic |
223
+ | Postgres driver setup (KV-only or with pgvector) | The `configure-pg-cache` topic |
224
+ | `cache.metrics()` aggregate snapshot + event bus for per-event reactions | The `observe-cache` topic |
225
+ | Error classes (`CacheConfigurationError`, `CacheUnsupportedError`, etc.) | The `handle-cache-errors` topic |
226
+ | Tests that touch cache code paths — `MockCacheDriver`, `MemoryCacheDriver` | The `test-cache-code` topic |
226
227
 
227
228
  ## Things NOT to do
228
229
 
@@ -280,8 +281,8 @@ cache.setCacheConfigurations({
280
281
  options: {
281
282
  pg: {
282
283
  client: pool,
283
- table: "warlock_cache", // optional, default
284
- ttl: "1h", // optional default
284
+ table: "warlock_cache", // optional, default
285
+ ttl: "1h", // optional default
285
286
  globalPrefix: "prod-app",
286
287
  },
287
288
  },
@@ -375,7 +376,7 @@ description: 'Configure cache.set''s third argument — ttl, expiresAt, tags, on
375
376
  await cache.set("name", "Jane", 600);
376
377
 
377
378
  // 2. String — human-readable duration, parsed via `ms`
378
- await cache.set("name", "Jane", "10m"); // "1s", "30m", "1h", "7d", "2 weeks"
379
+ await cache.set("name", "Jane", "10m"); // "1s", "30m", "1h", "7d", "2 weeks"
379
380
 
380
381
  // 3. Options object
381
382
  await cache.set("user:1", user, {
@@ -389,14 +390,14 @@ await cache.set("user:1", user, {
389
390
 
390
391
  ## Option keys
391
392
 
392
- | Key | Type | Notes |
393
- | --- | --- | --- |
394
- | `ttl` | `number \| string` | Relative expiry. Mutually exclusive with `expiresAt`. |
395
- | `expiresAt` | `number \| Date` | Absolute deadline (epoch ms or Date). Must be in the future. Mutually exclusive with `ttl`. |
396
- | `tags` | `string[]` | Inline equivalent of `cache.tags([...]).set(...)`. See the `use-cache-tags` topic. |
397
- | `onConflict` | `"create" \| "update" \| "upsert"` | See below. Default `"upsert"`. |
398
- | `driver` | `string` | Per-call driver override by registered name. |
399
- | `vector` | `number[]` | Embedding indexed alongside the entry for `cache.similar()` (see the `use-cache-similarity` topic). Drivers without similarity support throw `CacheUnsupportedError`. |
393
+ | Key | Type | Notes |
394
+ | ------------ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
395
+ | `ttl` | `number \| string` | Relative expiry. Mutually exclusive with `expiresAt`. |
396
+ | `expiresAt` | `number \| Date` | Absolute deadline (epoch ms or Date). Must be in the future. Mutually exclusive with `ttl`. |
397
+ | `tags` | `string[]` | Inline equivalent of `cache.tags([...]).set(...)`. See the `use-cache-tags` topic. |
398
+ | `onConflict` | `"create" \| "update" \| "upsert"` | See below. Default `"upsert"`. |
399
+ | `driver` | `string` | Per-call driver override by registered name. |
400
+ | `vector` | `number[]` | Embedding indexed alongside the entry for `cache.similar()` (see the `use-cache-similarity` topic). Drivers without similarity support throw `CacheUnsupportedError`. |
400
401
 
401
402
  ## TTL parsing
402
403
 
@@ -432,8 +433,8 @@ Conditional writes (`"create"` / `"update"`) return a `CacheSetResult`; uncondit
432
433
  Both of these throw `CacheConfigurationError`:
433
434
 
434
435
  ```ts
435
- await cache.set("k", v, { ttl: "1h", expiresAt: Date.now() + 1000 }); // both set
436
- await cache.set("k", v, { expiresAt: Date.now() - 1000 }); // past deadline
436
+ await cache.set("k", v, { ttl: "1h", expiresAt: Date.now() + 1000 }); // both set
437
+ await cache.set("k", v, { expiresAt: Date.now() - 1000 }); // past deadline
437
438
  ```
438
439
 
439
440
  ## Inline tags vs `cache.tags([...]).set(...)`
@@ -447,7 +448,7 @@ await cache.set("user:1", user, { tags: ["users", "tenant-42"] });
447
448
  // Fluent (useful when you already have a tagged instance)
448
449
  const users = cache.tags(["users"]);
449
450
  await users.set("user:1", user);
450
- await users.invalidate(); // drops every key tagged "users"
451
+ await users.invalidate(); // drops every key tagged "users"
451
452
  ```
452
453
 
453
454
  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()`.
@@ -457,8 +458,8 @@ Inline tag semantics are **additive**, never replace. A subsequent `set("user:1"
457
458
  Every call site using the old positional-TTL shape keeps working:
458
459
 
459
460
  ```ts
460
- await cache.set("k", v); // no TTL — driver default
461
- await cache.set("k", v, 3600); // seconds
461
+ await cache.set("k", v); // no TTL — driver default
462
+ await cache.set("k", v, 3600); // seconds
462
463
  await cache.set("k", v, undefined); // same as no TTL
463
464
  ```
464
465
 
@@ -485,14 +486,14 @@ import {
485
486
  } from "@warlock.js/cache";
486
487
  ```
487
488
 
488
- | Class | When it's thrown | How to react |
489
- | --- | --- | --- |
490
- | `CacheError` | Abstract base — don't throw directly, match against it to catch any cache error | `catch (e) { if (e instanceof CacheError) … }` |
491
- | `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. |
492
- | `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. |
493
- | `CacheDriverNotInitializedError` | Any data op called before `cache.init()` / `cache.use()` | Call `cache.init()` at app startup. Tests often forget this — add a `beforeEach`. |
494
- | `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. |
495
- | `CacheConcurrencyError` | Declared for future optimistic-concurrency exhaustion on Redis `update()` | Not thrown today. Reserved for the v2.1 `WATCH`/`MULTI` implementation. |
489
+ | Class | When it's thrown | How to react |
490
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
491
+ | `CacheError` | Abstract base — don't throw directly, match against it to catch any cache error | `catch (e) { if (e instanceof CacheError) … }` |
492
+ | `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. |
493
+ | `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. |
494
+ | `CacheDriverNotInitializedError` | Any data op called before `cache.init()` / `cache.use()` | Call `cache.init()` at app startup. Tests often forget this — add a `beforeEach`. |
495
+ | `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. |
496
+ | `CacheConcurrencyError` | Declared for future optimistic-concurrency exhaustion on Redis `update()` | Not thrown today. Reserved for the v2.1 `WATCH`/`MULTI` implementation. |
496
497
  | `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 the `pick-cache-driver` topic for the encoding contract. |
497
498
 
498
499
  ## Special case — `setNX` unsupported
@@ -613,15 +614,15 @@ Listeners attached via `cache.on(...)` survive driver switches the same way the
613
614
 
614
615
  ## Which one to reach for
615
616
 
616
- | Goal | Use |
617
- |---|---|
618
- | Show hit rate / latency in a dashboard | `cache.metrics()` |
619
- | Page on cache errors | `cache.on("error", ...)` |
620
- | Periodic export to Prometheus / StatsD | `cache.metrics()` + `setInterval` + `resetMetrics()` |
621
- | Audit log of every removal | `cache.on("removed", ...)` |
617
+ | Goal | Use |
618
+ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
619
+ | Show hit rate / latency in a dashboard | `cache.metrics()` |
620
+ | Page on cache errors | `cache.on("error", ...)` |
621
+ | Periodic export to Prometheus / StatsD | `cache.metrics()` + `setInterval` + `resetMetrics()` |
622
+ | Audit log of every removal | `cache.on("removed", ...)` |
622
623
  | See which tag invalidations ran, what they removed and how long they took | `cache.on("invalidated", ({ tags, keys, durationMs }) => ...)` — once per `tags([...]).invalidate()` |
623
- | Detect a specific anti-pattern (e.g. always-miss key) | `cache.on("miss", ...)` |
624
- | Debug "is the cache being hit at all?" in dev | `cache.metrics()` once at the end of a flow |
624
+ | Detect a specific anti-pattern (e.g. always-miss key) | `cache.on("miss", ...)` |
625
+ | Debug "is the cache being hit at all?" in dev | `cache.metrics()` once at the end of a flow |
625
626
 
626
627
  Both layers can coexist — events fire whether the metrics collector is attached or not.
627
628
 
@@ -751,30 +752,30 @@ description: 'Pick a cache driver — null / memory / memoryExtended / lru / fil
751
752
 
752
753
  Seven production drivers + a mock driver ship in-box. Pick by durability, scope, and workload.
753
754
 
754
- | Driver | Process scope | Persists on restart | Good for | Avoid when |
755
- | --- | --- | --- | --- | --- |
756
- | `null` | — | — | Disabling cache in tests; feature-flagging off | You actually want caching |
757
- | `memory` | Single process | No | Hot in-process data with default TTL; smallest latency | Multi-process / multi-node |
758
- | `memoryExtended` | Single process | No | Sliding-window TTL (TTL resets on every read) | Any multi-process deploy |
759
- | `lru` | Single process | No | Bounded in-memory caches (capacity-based eviction) | Need cross-process sharing |
760
- | `file` | Single host | Yes | Build artefacts, local dev persistence across restarts | Concurrency (no locks); multi-host |
761
- | `redis` | Shared | Yes (Redis-managed) | Anything shared across processes / nodes | Single-process-only workload — overkill |
762
- | `pg` | Shared | Yes (Postgres-managed) | You already run Postgres; semantic caching / RAG via pgvector | High-throughput hot reads (Redis is faster) |
755
+ | Driver | Process scope | Persists on restart | Good for | Avoid when |
756
+ | ---------------- | -------------- | ---------------------- | ------------------------------------------------------------- | ------------------------------------------- |
757
+ | `null` | — | — | Disabling cache in tests; feature-flagging off | You actually want caching |
758
+ | `memory` | Single process | No | Hot in-process data with default TTL; smallest latency | Multi-process / multi-node |
759
+ | `memoryExtended` | Single process | No | Sliding-window TTL (TTL resets on every read) | Any multi-process deploy |
760
+ | `lru` | Single process | No | Bounded in-memory caches (capacity-based eviction) | Need cross-process sharing |
761
+ | `file` | Single host | Yes | Build artefacts, local dev persistence across restarts | Concurrency (no locks); multi-host |
762
+ | `redis` | Shared | Yes (Redis-managed) | Anything shared across processes / nodes | Single-process-only workload — overkill |
763
+ | `pg` | Shared | Yes (Postgres-managed) | You already run Postgres; semantic caching / RAG via pgvector | High-throughput hot reads (Redis is faster) |
763
764
 
764
765
  ## Capability matrix
765
766
 
766
- | Capability | null | memory | memoryExt | lru | file | redis | pg |
767
- | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
768
- | `set` / `get` / `remove` / `flush` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
769
- | TTL (number or string; numeric strings = seconds) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
770
- | Sliding TTL on read | — | — | ✓ | — | — | — | — |
771
- | `removeNamespace` | noop | ✓ | ✓ | ✓ (prefix-scan) | ✓ | ✓ | ✓ (LIKE prefix) |
772
- | `onConflict: "create"` / `"update"` | noop | emulated | emulated | emulated | emulated | native `NX`/`XX` | native (INSERT ON CONFLICT) |
773
- | `increment` / `decrement` / `pull` | — | one process | one process | one process | one process | **cross-server** | **cross-server** |
774
- | `update()` / `merge()` | ✓ | one process | one process | one process | ✗ throws | **cross-server** (compare-and-set) | **cross-server** (compare-and-set) |
775
- | List sub-API | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (O(n) JSON blob today; native LPUSH/LRANGE in v2.1) | ✓ |
776
- | Tagged invalidation | noop | ✓ | ✓ | ✓ | ✓ (serialized index) | ✓ (native SET) | ✓ (JSON-array merge) |
777
- | `similar()` / `set({ vector })` | returns `[]` / noop | ✓ brute force | ✓ brute force | ✓ brute force | ✗ throws | ✗ throws (Phase 2 backlog) | ✓ (with `vector` config — pgvector) |
767
+ | Capability | null | memory | memoryExt | lru | file | redis | pg |
768
+ | ------------------------------------------------- | :-----------------: | :-----------: | :-----------: | :-------------: | :------------------: | :---------------------------------------------------: | :---------------------------------: |
769
+ | `set` / `get` / `remove` / `flush` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
770
+ | TTL (number or string; numeric strings = seconds) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
771
+ | Sliding TTL on read | — | — | ✓ | — | — | — | — |
772
+ | `removeNamespace` | noop | ✓ | ✓ | ✓ (prefix-scan) | ✓ | ✓ | ✓ (LIKE prefix) |
773
+ | `onConflict: "create"` / `"update"` | noop | emulated | emulated | emulated | emulated | native `NX`/`XX` | native (INSERT ON CONFLICT) |
774
+ | `increment` / `decrement` / `pull` | — | one process | one process | one process | one process | **cross-server** | **cross-server** |
775
+ | `update()` / `merge()` | ✓ | one process | one process | one process | ✗ throws | **cross-server** (compare-and-set) | **cross-server** (compare-and-set) |
776
+ | List sub-API | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (O(n) JSON blob today; native LPUSH/LRANGE in v2.1) | ✓ |
777
+ | Tagged invalidation | noop | ✓ | ✓ | ✓ | ✓ (serialized index) | ✓ (native SET) | ✓ (JSON-array merge) |
778
+ | `similar()` / `set({ vector })` | returns `[]` / noop | ✓ brute force | ✓ brute force | ✓ brute force | ✗ throws | ✗ throws (Phase 2 backlog) | ✓ (with `vector` config — pgvector) |
778
779
 
779
780
  ## Multi-server: use redis or pg
780
781
 
@@ -846,13 +847,14 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
846
847
  cache.setCacheConfigurations({
847
848
  default: "pg",
848
849
  drivers: { pg: PgCacheDriver },
849
- options: { pg: { table: "cache" } }, // static
850
+ options: { pg: { table: "cache" } }, // static
850
851
  });
851
852
 
852
- await cache.use("pg", { client: pool }); // runtime — skip init() in this case
853
+ await cache.use("pg", { client: pool }); // runtime — skip init() in this case
853
854
  ```
854
855
 
855
856
  Constraints:
857
+
856
858
  - The driver name must be registered in `setCacheConfigurations({ drivers })` — runtime options don't bypass registration.
857
859
  - 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.
858
860
  - Calling without options (or with `{}`) on an already-loaded driver returns the cached instance silently.
@@ -940,7 +942,7 @@ When you want the real read/write semantics (eviction, TTL expiry, similarity sc
940
942
 
941
943
  `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.
942
944
 
943
- ## Strategy 3 — `NullCacheDriver` when you need cache *off*
945
+ ## Strategy 3 — `NullCacheDriver` when you need cache _off_
944
946
 
945
947
  Use `NullCacheDriver` to disable caching entirely for code paths that should still work without a cache (graceful-degradation tests):
946
948
 
@@ -965,7 +967,9 @@ import { vi } from "vitest";
965
967
  class FakeRedisClient {
966
968
  public store = new Map<string, string>();
967
969
  private expires = new Map<string, number>();
968
- public on() { return this; }
970
+ public on() {
971
+ return this;
972
+ }
969
973
  public async connect() {}
970
974
  public async quit() {}
971
975
  public async set(key: string, value: string, opts?: { EX?: number; NX?: boolean; XX?: boolean }) {
@@ -993,13 +997,18 @@ class FakeRedisClient {
993
997
  const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
994
998
  return [...this.store.keys()].filter((k) => regex.test(k));
995
999
  }
996
- public async flushAll() { this.store.clear(); this.expires.clear(); }
1000
+ public async flushAll() {
1001
+ this.store.clear();
1002
+ this.expires.clear();
1003
+ }
997
1004
  public async incrBy(key: string, n: number) {
998
1005
  const next = Number(this.store.get(key) ?? 0) + n;
999
1006
  this.store.set(key, String(next));
1000
1007
  return next;
1001
1008
  }
1002
- public async decrBy(key: string, n: number) { return this.incrBy(key, -n); }
1009
+ public async decrBy(key: string, n: number) {
1010
+ return this.incrBy(key, -n);
1011
+ }
1003
1012
  }
1004
1013
 
1005
1014
  const fakeClient = new FakeRedisClient();
@@ -1023,7 +1032,7 @@ The in-package vitest config runs `silent: true`, but if you're outside the pack
1023
1032
  ```ts
1024
1033
  cache.setCacheConfigurations({
1025
1034
  default: "memory",
1026
- logging: false, // <-- here
1035
+ logging: false, // <-- here
1027
1036
  drivers: { memory: MemoryCacheDriver },
1028
1037
  options: { memory: {} },
1029
1038
  });
@@ -1042,7 +1051,7 @@ cache.on("hit", hits);
1042
1051
  await service.getProfile("1");
1043
1052
  await service.getProfile("1");
1044
1053
 
1045
- expect(hits).toHaveBeenCalledTimes(1); // second call was a hit
1054
+ expect(hits).toHaveBeenCalledTimes(1); // second call was a hit
1046
1055
  ```
1047
1056
 
1048
1057
  Listeners registered via `cache.on(...)` automatically attach to any driver loaded later, so order of `on()` vs `init()` doesn't matter.
@@ -1055,9 +1064,7 @@ The chain-serialization guarantee is worth testing when your code fans out concu
1055
1064
  await cache.set("counter", 0);
1056
1065
 
1057
1066
  await Promise.all(
1058
- Array.from({ length: 10 }, () =>
1059
- cache.update<number>("counter", (c) => (c ?? 0) + 1),
1060
- ),
1067
+ Array.from({ length: 10 }, () => cache.update<number>("counter", (c) => (c ?? 0) + 1)),
1061
1068
  );
1062
1069
 
1063
1070
  await expect(cache.get("counter")).resolves.toBe(10);
@@ -1082,7 +1089,7 @@ beforeEach(async () => {
1082
1089
 
1083
1090
  it("returns the most similar doc above threshold", async () => {
1084
1091
  await cache.set("a", { text: "alpha" }, { vector: [1, 0, 0] });
1085
- await cache.set("b", { text: "beta" }, { vector: [0, 1, 0] });
1092
+ await cache.set("b", { text: "beta" }, { vector: [0, 1, 0] });
1086
1093
 
1087
1094
  const hits = await cache.similar([1, 0, 0], { topK: 1, threshold: 0.5 });
1088
1095
 
@@ -1100,7 +1107,7 @@ The `pg` driver accepts any object satisfying `PgClientLike` (`{ query(text, val
1100
1107
 
1101
1108
  ---
1102
1109
  name: use-cache-atomic
1103
- 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 — the `use-cache-update-merge` topic; named-lock coordination — the `use-cache-lock` topic; competing libs `ioredis` `INCR`, native counters in a `Map`.'
1110
+ description: 'Atomic counters via cache.increment(key, by=1, { ttl }?) / 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 — the `use-cache-update-merge` topic; named-lock coordination — the `use-cache-lock` topic; competing libs `ioredis` `INCR`, native counters in a `Map`.'
1104
1111
  ---
1105
1112
 
1106
1113
  # Atomic counters — `cache.increment` / `cache.decrement`
@@ -1111,9 +1118,9 @@ code. Both return the **new** value after the operation.
1111
1118
  ```ts
1112
1119
  import { cache } from "@warlock.js/cache";
1113
1120
 
1114
- const views = await cache.increment(`post.${id}.views`); // +1 → 1, 2, 3…
1115
- const bulk = await cache.increment(`post.${id}.views`, 10); // +10
1116
- const left = await cache.decrement(`stock.${sku}`, 3); // -3
1121
+ const views = await cache.increment(`post.${id}.views`); // +1 → 1, 2, 3…
1122
+ const bulk = await cache.increment(`post.${id}.views`, 10); // +10
1123
+ const left = await cache.decrement(`stock.${sku}`, 3); // -3
1117
1124
  ```
1118
1125
 
1119
1126
  - A missing key is treated as `0`, so the first `increment` returns `by` (default `1`).
@@ -1125,18 +1132,24 @@ const left = await cache.decrement(`stock.${sku}`, 3); // -3
1125
1132
 
1126
1133
  `increment`, `decrement`, `pull` and `update` are atomic, but only some drivers are atomic **across servers**:
1127
1134
 
1128
- | Driver | `increment` / `decrement` | `pull` | `update` / `merge` |
1129
- |---|---|---|---|
1130
- | `redis` | cross-server (`INCRBY`) | cross-server (`GETDEL`) | cross-server (Lua compare-and-set, retries) |
1131
- | `pg` | cross-server (one upsert) | cross-server (`DELETE … RETURNING`) | cross-server (compare-and-set, retries) |
1132
- | `memory` / `memoryExtended` / `lru` / `mock` | one process | one process | one process |
1133
- | `file` | one process | one process | not supported (throws) |
1135
+ | Driver | `increment` / `decrement` | `pull` | `update` / `merge` |
1136
+ | -------------------------------------------- | ------------------------- | ----------------------------------- | ------------------------------------------- |
1137
+ | `redis` | cross-server (`INCRBY`) | cross-server (`GETDEL`) | cross-server (Lua compare-and-set, retries) |
1138
+ | `pg` | cross-server (one upsert) | cross-server (`DELETE … RETURNING`) | cross-server (compare-and-set, retries) |
1139
+ | `memory` / `memoryExtended` / `lru` / `mock` | one process | one process | one process |
1140
+ | `file` | one process | one process | not supported (throws) |
1134
1141
 
1135
1142
  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.
1136
1143
 
1137
1144
  ## TTL is kept
1138
1145
 
1139
- 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` (see the `use-cache-update-merge` topic) for objects.
1146
+ Pass the TTL on **every increment** of an expiring counter:
1147
+
1148
+ ```ts
1149
+ await cache.increment("login.failures", 1, { ttl: "1m" });
1150
+ ```
1151
+
1152
+ A missing or expired key starts at the amount with that TTL (seconds or duration string); an existing deadline is preserved. An explicit TTL also adds expiry to a live permanent counter, repairing counters left by an earlier seed/increment race. Without `options.ttl`, a fresh key uses the driver's default TTL. Redis applies the count and expiry in one Lua call; Postgres uses one upsert. This closes the race where a separate `set(key, 0, { ttl, onConflict: "create" })` seed expires just before the increment recreates the key. Tagged calls forward the option; namespaces use their default TTL unless overridden per call. Decrements keep existing TTLs too. Use `cache.update` (see the `use-cache-update-merge` topic) for objects.
1140
1153
 
1141
1154
  ## Common shapes
1142
1155
 
@@ -1193,11 +1206,14 @@ const missingIds = ids.filter((_, index) => users[index] === null);
1193
1206
  ## Write many — `setMany(items, ttl?)`
1194
1207
 
1195
1208
  ```ts
1196
- await cache.setMany({
1197
- "user.1": alice,
1198
- "user.2": bob,
1199
- "user.3": carol,
1200
- }, 3600); // optional TTL (seconds) applied to every entry
1209
+ await cache.setMany(
1210
+ {
1211
+ "user.1": alice,
1212
+ "user.2": bob,
1213
+ "user.3": carol,
1214
+ },
1215
+ 3600,
1216
+ ); // optional TTL (seconds) applied to every entry
1201
1217
  ```
1202
1218
 
1203
1219
  - Keys are the object keys; values are the object values.
@@ -1236,14 +1252,14 @@ Dedicated accessor for ordered collections — queues, recent-N buffers, sliding
1236
1252
  ```ts
1237
1253
  const recent = cache.list<Event>("recent-events");
1238
1254
 
1239
- await recent.push(event); // append to tail — returns new length
1240
- await recent.unshift(priorityEvent); // prepend to head — returns new length
1241
- const tail = await recent.pop(); // remove + return tail
1242
- const head = await recent.shift(); // remove + return head
1243
- const first10 = await recent.slice(0, 10); // view — does not mutate
1255
+ await recent.push(event); // append to tail — returns new length
1256
+ await recent.unshift(priorityEvent); // prepend to head — returns new length
1257
+ const tail = await recent.pop(); // remove + return tail
1258
+ const head = await recent.shift(); // remove + return head
1259
+ const first10 = await recent.slice(0, 10); // view — does not mutate
1244
1260
  const all = await recent.all();
1245
1261
  const count = await recent.length();
1246
- await recent.trim(0, 99); // keep only indices 0..99 inclusive
1262
+ await recent.trim(0, 99); // keep only indices 0..99 inclusive
1247
1263
  await recent.clear();
1248
1264
  ```
1249
1265
 
@@ -1255,8 +1271,8 @@ The generic flows through every method. Pass the element type at the accessor ca
1255
1271
  type Event = { type: string; at: number };
1256
1272
  const queue = cache.list<Event>("jobs:queue");
1257
1273
 
1258
- await queue.push({ type: "import", at: Date.now() }); // ✓
1259
- await queue.push("not an event" as never); // ✗ at the caller
1274
+ await queue.push({ type: "import", at: Date.now() }); // ✓
1275
+ await queue.push("not an event" as never); // ✗ at the caller
1260
1276
  ```
1261
1277
 
1262
1278
  ## Current performance characteristics
@@ -1278,7 +1294,7 @@ When a list becomes empty (e.g. after successive `pop()` / `shift()` / `trim(0,
1278
1294
  ```ts
1279
1295
  await recent.push("a");
1280
1296
  await recent.pop();
1281
- await cache.get("recent-events"); // null, not []
1297
+ await cache.get("recent-events"); // null, not []
1282
1298
  ```
1283
1299
 
1284
1300
  ## Typical recipes
@@ -1286,18 +1302,18 @@ await cache.get("recent-events"); // null, not []
1286
1302
  ```ts
1287
1303
  // Recent-N audit log
1288
1304
  const audit = cache.list<AuditEntry>("audit:recent");
1289
- await audit.unshift(entry); // newest at head
1290
- await audit.trim(0, 999); // keep most-recent 1000
1305
+ await audit.unshift(entry); // newest at head
1306
+ await audit.trim(0, 999); // keep most-recent 1000
1291
1307
 
1292
1308
  // Lightweight job queue (single-node)
1293
1309
  const queue = cache.list<Job>("jobs:pending");
1294
1310
  await queue.push(job);
1295
- const next = await queue.shift(); // FIFO
1311
+ const next = await queue.shift(); // FIFO
1296
1312
 
1297
1313
  // Stack
1298
1314
  const stack = cache.list<Frame>("stack");
1299
1315
  await stack.push(frame);
1300
- const top = await stack.pop(); // LIFO
1316
+ const top = await stack.pop(); // LIFO
1301
1317
  ```
1302
1318
 
1303
1319
  ## What lists are NOT for
@@ -1351,9 +1367,7 @@ TTL rules: a number or a bare numeric string (`"90"`) is **seconds**; `"500ms"`
1351
1367
  ## Return shape — discriminated union
1352
1368
 
1353
1369
  ```ts
1354
- type LockOutcome<T> =
1355
- | { acquired: true; value: T }
1356
- | { acquired: false };
1370
+ type LockOutcome<T> = { acquired: true; value: T } | { acquired: false };
1357
1371
  ```
1358
1372
 
1359
1373
  Unambiguous even when `fn` returns `undefined`. Narrow with TS:
@@ -1362,7 +1376,7 @@ Unambiguous even when `fn` returns `undefined`. Narrow with TS:
1362
1376
  const outcome = await cache.lock("lock.x", "1m", async () => compute());
1363
1377
 
1364
1378
  if (outcome.acquired) {
1365
- console.log(outcome.value); // typed
1379
+ console.log(outcome.value); // typed
1366
1380
  } else {
1367
1381
  console.log("someone else is running");
1368
1382
  }
@@ -1373,19 +1387,15 @@ if (outcome.acquired) {
1373
1387
  ### Cron on only one server
1374
1388
 
1375
1389
  ```ts
1376
- cron.daily("3am", () =>
1377
- cache.lock("lock.cleanup", "30m", () => db.cleanup()),
1378
- );
1390
+ cron.daily("3am", () => cache.lock("lock.cleanup", "30m", () => db.cleanup()));
1379
1391
  ```
1380
1392
 
1381
1393
  ### Idempotent webhook
1382
1394
 
1383
1395
  ```ts
1384
1396
  app.post("/webhooks/stripe", async (req, res) => {
1385
- const outcome = await cache.lock(
1386
- `webhook.stripe.${req.body.id}`,
1387
- "24h",
1388
- () => processStripeEvent(req.body),
1397
+ const outcome = await cache.lock(`webhook.stripe.${req.body.id}`, "24h", () =>
1398
+ processStripeEvent(req.body),
1389
1399
  );
1390
1400
 
1391
1401
  if (!outcome.acquired) {
@@ -1410,13 +1420,13 @@ await cache.lock(
1410
1420
 
1411
1421
  ## Driver behavior
1412
1422
 
1413
- | Driver | Cross-process safe? |
1414
- |--------|:-:|
1415
- | `redis` | ✅ Native `SET … NX EX`, Lua release |
1416
- | `pg` | ✅ `INSERT … ON CONFLICT`, one-statement release |
1423
+ | Driver | Cross-process safe? |
1424
+ | ----------------------------------- | :-----------------------------------------------: |
1425
+ | `redis` | ✅ Native `SET … NX EX`, Lua release |
1426
+ | `pg` | ✅ `INSERT … ON CONFLICT`, one-statement release |
1417
1427
  | `memory` / `memoryExtended` / `lru` | ❌ In-process only (exclusive within the process) |
1418
- | `file` | ⚠️ Single-host only (exclusive create) |
1419
- | `null` | n/a — always "acquires" |
1428
+ | `file` | ⚠️ Single-host only (exclusive create) |
1429
+ | `null` | n/a — always "acquires" |
1420
1430
 
1421
1431
  ## Gotchas
1422
1432
 
@@ -1442,10 +1452,10 @@ When you'll touch the same prefix more than a couple of times, grab a scoped han
1442
1452
  ```ts
1443
1453
  const chat = cache.namespace(`chats.${id}`, { ttl: "30d", tags: [`user.${userId}`] });
1444
1454
 
1445
- await chat.set("messages.10", msg); // chats.<id>.messages.10, 30d, user.<userId>
1446
- await chat.set("draft", d, { ttl: "1h" }); // per-call ttl wins
1447
- await chat.tags(["unread"]).set("ping", p); // tags merge: user.<userId> + unread
1448
- await chat.clear(); // sugar for removeNamespace
1455
+ await chat.set("messages.10", msg); // chats.<id>.messages.10, 30d, user.<userId>
1456
+ await chat.set("draft", d, { ttl: "1h" }); // per-call ttl wins
1457
+ await chat.tags(["unread"]).set("ping", p); // tags merge: user.<userId> + unread
1458
+ await chat.clear(); // sugar for removeNamespace
1449
1459
  ```
1450
1460
 
1451
1461
  Scopes are pure views — same connection, same driver, no extra state. Per-call options always win over scope defaults; tags merge additively.
@@ -1454,9 +1464,9 @@ Scopes are pure views — same connection, same driver, no extra state. Per-call
1454
1464
 
1455
1465
  ```ts
1456
1466
  const chat = cache.namespace(`chats.${id}`, { ttl: "30d" });
1457
- const typing = chat.namespace("typing", { ttl: "5s" }); // overrides parent ttl
1467
+ const typing = chat.namespace("typing", { ttl: "5s" }); // overrides parent ttl
1458
1468
 
1459
- await typing.set("user.42", true); // chats.<id>.typing.user.42, 5s
1469
+ await typing.set("user.42", true); // chats.<id>.typing.user.42, 5s
1460
1470
  ```
1461
1471
 
1462
1472
  Nested scopes inherit defaults and can override. Tags accumulate.
@@ -1471,14 +1481,14 @@ Inline prefixes are still fine for one-off writes.
1471
1481
 
1472
1482
  ## Plain `removeNamespace` when you already have the prefix
1473
1483
 
1474
- When you *do* know the prefix string and don't need a scoped handle for repeated reads/writes:
1484
+ When you _do_ know the prefix string and don't need a scoped handle for repeated reads/writes:
1475
1485
 
1476
1486
  ```ts
1477
1487
  await cache.set("user:1:profile", profile);
1478
- await cache.set("user:1:prefs", prefs);
1488
+ await cache.set("user:1:prefs", prefs);
1479
1489
  await cache.set("user:2:profile", otherProfile);
1480
1490
 
1481
- await cache.removeNamespace("user.1"); // drops both user:1 entries, keeps user:2
1491
+ await cache.removeNamespace("user.1"); // drops both user:1 entries, keeps user:2
1482
1492
  ```
1483
1493
 
1484
1494
  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 the `pick-cache-driver` topic for the file-path-containment and redis-glob-escaping details.
@@ -1501,10 +1511,8 @@ options: {
1501
1511
  ```ts
1502
1512
  const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
1503
1513
 
1504
- await feed.swr(
1505
- "home",
1506
- { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
1507
- () => buildHomeFeed(userId),
1514
+ await feed.swr("home", { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] }, () =>
1515
+ buildHomeFeed(userId),
1508
1516
  );
1509
1517
  // stored at feed.<userId>.home, tagged [user.<userId>, computed]
1510
1518
  ```
@@ -1546,21 +1554,21 @@ const hits = await cache.similar<T>(queryVec, {
1546
1554
 
1547
1555
  ## Capability matrix
1548
1556
 
1549
- | Driver | `similar()` |
1550
- |---|---|
1557
+ | Driver | `similar()` |
1558
+ | ----------------------------------- | -------------------------------------------------- |
1551
1559
  | `memory` / `memoryExtended` / `lru` | ✅ Brute force (O(N)) — dev only past ~10k entries |
1552
- | `pg` *with* `options.pg.vector` | ✅ pgvector + HNSW/IVFFlat index |
1553
- | `pg` *without* `options.pg.vector` | ❌ Throws `CacheUnsupportedError` |
1554
- | `redis` | ❌ Throws (RediSearch on backlog) |
1555
- | `file` | ❌ Throws |
1556
- | `null` | Returns `[]` |
1560
+ | `pg` _with_ `options.pg.vector` | ✅ pgvector + HNSW/IVFFlat index |
1561
+ | `pg` _without_ `options.pg.vector` | ❌ Throws `CacheUnsupportedError` |
1562
+ | `redis` | ❌ Throws (RediSearch on backlog) |
1563
+ | `file` | ❌ Throws |
1564
+ | `null` | Returns `[]` |
1557
1565
 
1558
1566
  ## Always-true facts
1559
1567
 
1560
1568
  1. **Cache is embedding-agnostic.** Caller computes vectors. The cache stores and ranks; it doesn't call out to an embedder.
1561
1569
  2. **Only entries written with `set({ vector })` show up.** A plain `set` adds the entry as KV — invisible to `similar()`.
1562
1570
  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.
1563
- 4. **Tag filter narrows the candidate pool *before* ranking** — union semantics (entry must carry at least one of the listed tags).
1571
+ 4. **Tag filter narrows the candidate pool _before_ ranking** — union semantics (entry must carry at least one of the listed tags).
1564
1572
  5. **Dimension mismatch throws `CacheConfigurationError`** at both `set({ vector })` and `similar()` time. Don't switch embedders without re-indexing.
1565
1573
  6. **TTL + LRU eviction also drop the vector** — expired or evicted entries are invisible to `similar()`.
1566
1574
 
@@ -1573,7 +1581,7 @@ const queryVec = await embed(prompt);
1573
1581
  const hits = await cache.similar<Answer>(queryVec, { topK: 1, threshold: 0.92 });
1574
1582
 
1575
1583
  if (hits.length > 0) {
1576
- return hits[0].value; // skip the LLM call
1584
+ return hits[0].value; // skip the LLM call
1577
1585
  }
1578
1586
 
1579
1587
  const answer = await llm.complete(prompt);
@@ -1632,7 +1640,7 @@ Tags let you invalidate by a label rather than by enumerating every key. Use the
1632
1640
  ```ts
1633
1641
  // Inline — terser when you know the tags up front
1634
1642
  await cache.set("user:1:profile", profile, { tags: ["users", "tenant-42"] });
1635
- await cache.set("user:1:prefs", prefs, { tags: ["users", "tenant-42"] });
1643
+ await cache.set("user:1:prefs", prefs, { tags: ["users", "tenant-42"] });
1636
1644
 
1637
1645
  // Fluent — useful when you already have a tagged handle
1638
1646
  const users = cache.tags(["users"]);
@@ -1654,11 +1662,11 @@ Multi-tag is **union** semantics: an entry is invalidated if it carries **at lea
1654
1662
 
1655
1663
  ## When to reach for tags vs namespaces
1656
1664
 
1657
- | Use case | Reach for |
1658
- | --- | --- |
1659
- | The keys share a known prefix | `cache.removeNamespace("prefix")` (the `use-cache-namespace` topic) |
1660
- | The keys are spread across prefixes, tied by entity | Tags |
1661
- | Both apply | Tags — more flexible; cheap on most drivers |
1665
+ | Use case | Reach for |
1666
+ | --------------------------------------------------- | ------------------------------------------------------------------- |
1667
+ | The keys share a known prefix | `cache.removeNamespace("prefix")` (the `use-cache-namespace` topic) |
1668
+ | The keys are spread across prefixes, tied by entity | Tags |
1669
+ | Both apply | Tags — more flexible; cheap on most drivers |
1662
1670
 
1663
1671
  Namespaces are cheaper (no reverse index). Tags are more powerful (any key can carry any tag).
1664
1672
 
@@ -1668,13 +1676,13 @@ Subsequent `set("user:1", ...)` with **no** `tags` leaves previous associations
1668
1676
 
1669
1677
  ## Driver behavior
1670
1678
 
1671
- | Driver | Tag invalidation |
1672
- | --- | :-: |
1673
- | `null` | noop |
1679
+ | Driver | Tag invalidation |
1680
+ | ----------------------------------- | :----------------------------------------------------------: |
1681
+ | `null` | noop |
1674
1682
  | `memory` / `memoryExtended` / `lru` | ✓ (separate index store; eviction and expiry never touch it) |
1675
- | `file` | ✓ (serialized index) |
1676
- | `redis` | ✓ (native SET inside the prefix) |
1677
- | `pg` | ✓ (JSON-array merge on the index row) |
1683
+ | `file` | ✓ (serialized index) |
1684
+ | `redis` | ✓ (native SET inside the prefix) |
1685
+ | `pg` | ✓ (JSON-array merge on the index row) |
1678
1686
 
1679
1687
  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.
1680
1688
 
@@ -1696,7 +1704,7 @@ Tags re-apply on every successful refresh — see the `use-swr` topic.
1696
1704
  const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
1697
1705
  const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
1698
1706
 
1699
- await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
1707
+ await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
1700
1708
  ```
1701
1709
 
1702
1710
  See the `use-cached-hof` topic.
@@ -1736,7 +1744,7 @@ await cache.update<UserState>("user:1:state", (current) => ({
1736
1744
  // Conditional update — return null to remove
1737
1745
  await cache.update<Session>("session:abc", (current) => {
1738
1746
  if (!current || current.expired) {
1739
- return null; // removes the key
1747
+ return null; // removes the key
1740
1748
  }
1741
1749
  return { ...current, extendedAt: Date.now() };
1742
1750
  });
@@ -1779,21 +1787,21 @@ await Promise.all(
1779
1787
  ),
1780
1788
  );
1781
1789
 
1782
- await cache.get("counter"); // 10 — not a lost-update
1790
+ await cache.get("counter"); // 10 — not a lost-update
1783
1791
  ```
1784
1792
 
1785
1793
  The per-key lock map lives on the driver instance and is cleared after each chain link finishes. No leak.
1786
1794
 
1787
1795
  ## When to reach for what
1788
1796
 
1789
- | Task | Use |
1790
- | --- | --- |
1791
- | "Add 1 to this counter" | `increment()` (Redis-atomic via `INCRBY`; in-process elsewhere) |
1792
- | "Change one field on a cached object" | `merge()` |
1793
- | "Read-decide-maybe-write, possibly remove" | `update()` |
1794
- | "Only set if missing" | `set(k, v, { onConflict: "create" })` |
1795
- | "Only set if already exists" | `set(k, v, { onConflict: "update" })` |
1796
- | "Read-then-delete atomically" | `pull()` |
1797
+ | Task | Use |
1798
+ | ------------------------------------------ | --------------------------------------------------------------- |
1799
+ | "Add 1 to this counter" | `increment()` (Redis-atomic via `INCRBY`; in-process elsewhere) |
1800
+ | "Change one field on a cached object" | `merge()` |
1801
+ | "Read-decide-maybe-write, possibly remove" | `update()` |
1802
+ | "Only set if missing" | `set(k, v, { onConflict: "create" })` |
1803
+ | "Only set if already exists" | `set(k, v, { onConflict: "update" })` |
1804
+ | "Read-then-delete atomically" | `pull()` |
1797
1805
 
1798
1806
 
1799
1807
  ## use-cache-utils `@warlock.js/cache/use-cache-utils/SKILL.md`
@@ -1815,17 +1823,17 @@ driver or do cache-adjacent work outside the manager.
1815
1823
  ```ts
1816
1824
  import { parseTtl, expiresAtToTtl, resolveTtl } from "@warlock.js/cache";
1817
1825
 
1818
- parseTtl(3600); // 3600
1819
- parseTtl("1h"); // 3600 (duration string via `ms`)
1820
- parseTtl(Infinity); // Infinity (no expiry)
1821
- parseTtl(-5); // throws CacheConfigurationError
1826
+ parseTtl(3600); // 3600
1827
+ parseTtl("1h"); // 3600 (duration string via `ms`)
1828
+ parseTtl(Infinity); // Infinity (no expiry)
1829
+ parseTtl(-5); // throws CacheConfigurationError
1822
1830
 
1823
1831
  expiresAtToTtl(new Date(Date.now() + 60_000)); // ~60 (absolute → relative seconds)
1824
- expiresAtToTtl(Date.now() - 1000); // throws — deadline in the past
1832
+ expiresAtToTtl(Date.now() - 1000); // throws — deadline in the past
1825
1833
 
1826
1834
  // caller ttl > expiresAt > fallback; ttl+expiresAt together throws
1827
- resolveTtl("1h", undefined, Infinity); // 3600
1828
- resolveTtl(undefined, undefined, 1800); // 1800 (fallback)
1835
+ resolveTtl("1h", undefined, Infinity); // 3600
1836
+ resolveTtl(undefined, undefined, 1800); // 1800 (fallback)
1829
1837
  ```
1830
1838
 
1831
1839
  ## Option normalizers
@@ -1836,10 +1844,10 @@ Coerce the polymorphic 2nd/3rd argument of `set`/`remember` into a uniform shape
1836
1844
  ```ts
1837
1845
  import { normalizeToOptions, normalizeToRememberOptions } from "@warlock.js/cache";
1838
1846
 
1839
- normalizeToOptions(60); // { ttl: 60 }
1840
- normalizeToOptions("1h"); // { ttl: "1h" }
1841
- normalizeToOptions({ tags: ["x"] }); // returned as-is
1842
- normalizeToRememberOptions("1h"); // { ttl: "1h" } (no expiresAt/onConflict)
1847
+ normalizeToOptions(60); // { ttl: 60 }
1848
+ normalizeToOptions("1h"); // { ttl: "1h" }
1849
+ normalizeToOptions({ tags: ["x"] }); // returned as-is
1850
+ normalizeToRememberOptions("1h"); // { ttl: "1h" } (no expiresAt/onConflict)
1843
1851
  ```
1844
1852
 
1845
1853
  ## Key + tag helpers
@@ -1847,14 +1855,14 @@ normalizeToRememberOptions("1h"); // { ttl: "1h" } (no expiresAt/onConfl
1847
1855
  ```ts
1848
1856
  import { parseCacheKey, mergeTagSets, injectTags } from "@warlock.js/cache";
1849
1857
 
1850
- parseCacheKey("users:1"); // "users.1"
1851
- parseCacheKey({ page: 1, q: "John" }); // "page.1.q.John"
1858
+ parseCacheKey("users:1"); // "users.1"
1859
+ parseCacheKey({ page: 1, q: "John" }); // "page.1.q.John"
1852
1860
  parseCacheKey("user:1", { globalPrefix: "app" }); // "app.user.1"
1853
1861
 
1854
- mergeTagSets(["a", "b"], ["b", "c"]); // ["a","b","c"] (deduped union)
1855
- mergeTagSets(undefined, undefined); // undefined
1862
+ mergeTagSets(["a", "b"], ["b", "c"]); // ["a","b","c"] (deduped union)
1863
+ mergeTagSets(undefined, undefined); // undefined
1856
1864
 
1857
- injectTags({ ttl: "1h" }, ["unread"]); // { ttl: "1h", tags: ["unread"] } (pure, no mutation)
1865
+ injectTags({ ttl: "1h" }, ["unread"]); // { ttl: "1h", tags: ["unread"] } (pure, no mutation)
1858
1866
  ```
1859
1867
 
1860
1868
  ## Vector scoring
@@ -1864,7 +1872,7 @@ import { cosineSimilarity } from "@warlock.js/cache";
1864
1872
 
1865
1873
  cosineSimilarity([1, 0, 0], [1, 0, 0]); // 1
1866
1874
  cosineSimilarity([1, 0, 0], [0, 1, 0]); // 0
1867
- cosineSimilarity([1, 2, 3], [1, 2]); // throws — dimension mismatch
1875
+ cosineSimilarity([1, 2, 3], [1, 2]); // throws — dimension mismatch
1868
1876
  ```
1869
1877
 
1870
1878
  Powers the brute-force `cache.similar()` on the memory drivers — reach for it
@@ -1903,6 +1911,7 @@ description: 'Wrap an async function with cached(fn, options) — declare the ca
1903
1911
  ## When to use
1904
1912
 
1905
1913
  Reach for `cached()` instead of `cache.remember()` when:
1914
+
1906
1915
  - You have a function you'll call from many places.
1907
1916
  - You want the caching strategy declared once, not repeated at every call site.
1908
1917
  - You want `.invalidate()` available without manually deriving keys.
@@ -1931,11 +1940,11 @@ cached(fn, {
1931
1940
 
1932
1941
  ## Auto-key rules (shorthand only)
1933
1942
 
1934
- | Args | Key |
1935
- |------|-----|
1936
- | None | `prefix` |
1937
- | Any args | `prefix.<16 hex>` — sha1 of a stable JSON encoding of the args, truncated |
1938
- | `Map` / `Set` / function / symbol / class instance / circular | throws `CacheConfigurationError` |
1943
+ | Args | Key |
1944
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------- |
1945
+ | None | `prefix` |
1946
+ | Any args | `prefix.<16 hex>` — sha1 of a stable JSON encoding of the args, truncated |
1947
+ | `Map` / `Set` / function / symbol / class instance / circular | throws `CacheConfigurationError` |
1939
1948
 
1940
1949
  The hash keeps distinct args on distinct keys (`("1.private", "")` ≠ `("1", "private")`). Object keys are sorted; `Date` → ISO string; order of args matters. Never build keys by hand: call `.invalidate(...args)`, which honours `config.driver`. When auto-key fails, use the options form with a custom `key` fn.
1941
1950
 
@@ -1969,7 +1978,7 @@ await getUser.invalidate(42);
1969
1978
  const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
1970
1979
  const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
1971
1980
 
1972
- await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
1981
+ await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
1973
1982
  ```
1974
1983
 
1975
1984
  See the `use-cache-tags` topic.
@@ -1979,7 +1988,7 @@ See the `use-cache-tags` topic.
1979
1988
  ```ts
1980
1989
  const getCategoryMeta = cached(
1981
1990
  (filters: Filters) => db.categories.meta(filters.category),
1982
- { key: (f) => `category.meta.${f.category}`, ttl: "1h" }, // ignores `sort`, `page`
1991
+ { key: (f) => `category.meta.${f.category}`, ttl: "1h" }, // ignores `sort`, `page`
1983
1992
  );
1984
1993
  ```
1985
1994
 
@@ -2024,11 +2033,11 @@ Use `cache.remember()` when freshness is non-negotiable — auth, balances, bill
2024
2033
  │ no upstream call │ bg refresh │ like a miss
2025
2034
  ```
2026
2035
 
2027
- | Window | Behavior |
2028
- |---|---|
2029
- | `now < freshTtl` | Return cached. No upstream call. |
2036
+ | Window | Behavior |
2037
+ | --------------------------- | ---------------------------------------------------------------------------------------- |
2038
+ | `now < freshTtl` | Return cached. No upstream call. |
2030
2039
  | `freshTtl ≤ now < staleTtl` | Return cached immediately. Run `fn()` in background; next read sees the refreshed value. |
2031
- | `now ≥ staleTtl` | Block on `fn()`. Same as `remember()`. |
2040
+ | `now ≥ staleTtl` | Block on `fn()`. Same as `remember()`. |
2032
2041
 
2033
2042
  ## API
2034
2043
 
@@ -2057,12 +2066,12 @@ await cache.swr(
2057
2066
 
2058
2067
  ## Driver support
2059
2068
 
2060
- | Driver | Background refresh |
2061
- |---|---|
2062
- | memory / memoryExtended / lru / file / mock | ✅ Full |
2063
- | redis | ✅ Full (sidecar key for staleAt — backwards-compatible) |
2064
- | pg | ✅ Full (`stale_at TIMESTAMPTZ` column — provision via `driver.schema()`) |
2065
- | null | ❌ Always-fetch (null caches nothing) |
2069
+ | Driver | Background refresh |
2070
+ | ------------------------------------------- | ------------------------------------------------------------------------- |
2071
+ | memory / memoryExtended / lru / file / mock | ✅ Full |
2072
+ | redis | ✅ Full (sidecar key for staleAt — backwards-compatible) |
2073
+ | pg | ✅ Full (`stale_at TIMESTAMPTZ` column — provision via `driver.schema()`) |
2074
+ | null | ❌ Always-fetch (null caches nothing) |
2066
2075
 
2067
2076
  ## Common shapes
2068
2077
 
@@ -2078,9 +2087,7 @@ await cache.swr(`dashboard.${tenantId}`, { freshTtl: "5m", staleTtl: "1h" }, ()
2078
2087
  );
2079
2088
 
2080
2089
  // Third-party API — degrade gracefully when upstream is down
2081
- await cache.swr("exchange.rates", { freshTtl: "10m", staleTtl: "24h" }, () =>
2082
- fetchFromForexAPI(),
2083
- );
2090
+ await cache.swr("exchange.rates", { freshTtl: "10m", staleTtl: "24h" }, () => fetchFromForexAPI());
2084
2091
  ```
2085
2092
 
2086
2093
  ## Through scoped caches
@@ -2088,10 +2095,8 @@ await cache.swr("exchange.rates", { freshTtl: "10m", staleTtl: "24h" }, () =>
2088
2095
  ```ts
2089
2096
  const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
2090
2097
 
2091
- await feed.swr(
2092
- "home",
2093
- { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
2094
- () => buildHomeFeed(userId),
2098
+ await feed.swr("home", { freshTtl: "30s", staleTtl: "10m", tags: ["computed"] }, () =>
2099
+ buildHomeFeed(userId),
2095
2100
  );
2096
2101
  // stored at feed.<userId>.home, tagged [user.<userId>, computed]
2097
2102
  ```
@@ -2099,7 +2104,7 @@ await feed.swr(
2099
2104
  ## Things NOT to do
2100
2105
 
2101
2106
  - 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.
2102
- - 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.
2107
+ - 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.
2103
2108
  - Don't ignore `error` events. A persistent stream of refresh failures means upstream is broken and the cache is masking it.
2104
2109
  - Don't reach for SWR on the null driver — it caches nothing, so SWR always blocks.
2105
2110