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