@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.
- package/CHANGELOG.md +20 -8
- package/README.md +12 -21
- package/cjs/index.cjs +46 -27
- package/cjs/index.cjs.map +1 -1
- package/esm/cache-manager.d.mts +3 -1
- package/esm/cache-manager.d.mts.map +1 -1
- package/esm/cache-manager.mjs +2 -2
- package/esm/cache-manager.mjs.map +1 -1
- package/esm/drivers/base-cache-driver.d.mts +3 -1
- package/esm/drivers/base-cache-driver.d.mts.map +1 -1
- package/esm/drivers/base-cache-driver.mjs +4 -3
- package/esm/drivers/base-cache-driver.mjs.map +1 -1
- package/esm/drivers/file-cache-driver.d.mts.map +1 -1
- package/esm/drivers/file-cache-driver.mjs.map +1 -1
- package/esm/drivers/lru-memory-cache-driver.d.mts +3 -1
- package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -1
- package/esm/drivers/lru-memory-cache-driver.mjs +6 -4
- package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -1
- package/esm/drivers/memory-cache-driver.d.mts +3 -1
- package/esm/drivers/memory-cache-driver.d.mts.map +1 -1
- package/esm/drivers/memory-cache-driver.mjs +9 -3
- package/esm/drivers/memory-cache-driver.mjs.map +1 -1
- package/esm/drivers/mock-cache-driver.d.mts +3 -1
- package/esm/drivers/mock-cache-driver.d.mts.map +1 -1
- package/esm/drivers/mock-cache-driver.mjs +11 -4
- package/esm/drivers/mock-cache-driver.mjs.map +1 -1
- package/esm/drivers/pg-cache-driver.d.mts +3 -1
- package/esm/drivers/pg-cache-driver.d.mts.map +1 -1
- package/esm/drivers/pg-cache-driver.mjs +6 -3
- package/esm/drivers/pg-cache-driver.mjs.map +1 -1
- package/esm/drivers/redis-cache-driver.d.mts +4 -2
- package/esm/drivers/redis-cache-driver.d.mts.map +1 -1
- package/esm/drivers/redis-cache-driver.mjs +4 -3
- package/esm/drivers/redis-cache-driver.mjs.map +1 -1
- package/esm/list/memory-cache-list.mjs.map +1 -1
- package/esm/metrics.d.mts.map +1 -1
- package/esm/metrics.mjs.map +1 -1
- package/esm/scoped-cache.d.mts +3 -1
- package/esm/scoped-cache.d.mts.map +1 -1
- package/esm/scoped-cache.mjs +2 -2
- package/esm/scoped-cache.mjs.map +1 -1
- package/esm/tagged-cache.d.mts +3 -1
- package/esm/tagged-cache.d.mts.map +1 -1
- package/esm/tagged-cache.mjs +2 -2
- package/esm/tagged-cache.mjs.map +1 -1
- package/esm/tagged-scoped-cache.d.mts +3 -1
- package/esm/tagged-scoped-cache.d.mts.map +1 -1
- package/esm/tagged-scoped-cache.mjs +2 -2
- package/esm/tagged-scoped-cache.mjs.map +1 -1
- package/esm/types.d.mts +16 -5
- package/esm/types.d.mts.map +1 -1
- package/esm/types.mjs.map +1 -1
- package/esm/utils.d.mts.map +1 -1
- package/esm/utils.mjs.map +1 -1
- package/llms-full.txt +229 -224
- package/llms.txt +1 -1
- package/package.json +3 -3
- package/skills/apply-cache-patterns/SKILL.md +3 -2
- package/skills/cache-basics/SKILL.md +24 -24
- package/skills/configure-pg-cache/SKILL.md +2 -2
- package/skills/configure-set-options/SKILL.md +14 -14
- package/skills/handle-cache-errors/SKILL.md +8 -8
- package/skills/index.md +4 -1
- package/skills/observe-cache/SKILL.md +8 -8
- package/skills/pick-cache-driver/SKILL.md +24 -23
- package/skills/test-cache-code/SKILL.md +15 -10
- package/skills/use-cache-atomic/SKILL.md +17 -11
- package/skills/use-cache-bulk/SKILL.md +8 -5
- package/skills/use-cache-list/SKILL.md +13 -13
- package/skills/use-cache-lock/SKILL.md +11 -17
- package/skills/use-cache-namespace/SKILL.md +11 -13
- package/skills/use-cache-similarity/SKILL.md +9 -9
- package/skills/use-cache-tags/SKILL.md +13 -13
- package/skills/use-cache-update-merge/SKILL.md +10 -10
- package/skills/use-cache-utils/SKILL.md +17 -17
- package/skills/use-cached-hof/SKILL.md +8 -7
- 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);
|
|
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("");
|
|
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");
|
|
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");
|
|
185
|
-
const taken = await cache.pull<User>("user.1");
|
|
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();
|
|
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");
|
|
200
|
-
await cache.increment("post.42.views", 10);
|
|
201
|
-
await cache.decrement("inventory.sku-x");
|
|
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…
|
|
210
|
-
|
|
|
211
|
-
| Choosing a driver, configuring it, or understanding what each one does best
|
|
212
|
-
| The `set` options object (`ttl`, `expiresAt`, `tags`, `onConflict`, `driver`, `vector`)
|
|
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?)`
|
|
215
|
-
| Tag-based invalidation — `cache.tags([...]).invalidate()`
|
|
216
|
-
| Stale-while-revalidate — `cache.swr(...)`
|
|
217
|
-
| Wrapping a function with `cached()` — HOF memoization with `.invalidate()`
|
|
218
|
-
| Distributed locks — `cache.lock(key, ttl, fn)` with auto-release
|
|
219
|
-
| Queues, recent-N buffers, `push`/`shift`/`trim` — the list sub-API
|
|
220
|
-
| Atomic read-modify-write via `update()` and `merge()`
|
|
221
|
-
| Similarity retrieval — `set({ vector })` + `cache.similar(...)`
|
|
222
|
-
| Postgres driver setup (KV-only or with pgvector)
|
|
223
|
-
| `cache.metrics()` aggregate snapshot + event bus for per-event reactions
|
|
224
|
-
| Error classes (`CacheConfigurationError`, `CacheUnsupportedError`, etc.)
|
|
225
|
-
| Tests that touch cache code paths — `MockCacheDriver`, `MemoryCacheDriver`
|
|
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",
|
|
284
|
-
ttl: "1h",
|
|
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");
|
|
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
|
|
393
|
-
|
|
|
394
|
-
| `ttl`
|
|
395
|
-
| `expiresAt`
|
|
396
|
-
| `tags`
|
|
397
|
-
| `onConflict` | `"create" \| "update" \| "upsert"` | See below. Default `"upsert"`.
|
|
398
|
-
| `driver`
|
|
399
|
-
| `vector`
|
|
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 });
|
|
436
|
-
await cache.set("k", v, { expiresAt: Date.now() - 1000 });
|
|
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();
|
|
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);
|
|
461
|
-
await cache.set("k", v, 3600);
|
|
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
|
|
489
|
-
|
|
|
490
|
-
| `CacheError`
|
|
491
|
-
| `CacheConfigurationError`
|
|
492
|
-
| `CacheConnectionError`
|
|
493
|
-
| `CacheDriverNotInitializedError`
|
|
494
|
-
| `CacheUnsupportedError`
|
|
495
|
-
| `CacheConcurrencyError`
|
|
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
|
|
617
|
-
|
|
618
|
-
| Show hit rate / latency in a dashboard
|
|
619
|
-
| Page on cache errors
|
|
620
|
-
| Periodic export to Prometheus / StatsD
|
|
621
|
-
| Audit log of every removal
|
|
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)
|
|
624
|
-
| Debug "is the cache being hit at all?" in dev
|
|
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
|
|
755
|
-
|
|
|
756
|
-
| `null`
|
|
757
|
-
| `memory`
|
|
758
|
-
| `memoryExtended` | Single process | No
|
|
759
|
-
| `lru`
|
|
760
|
-
| `file`
|
|
761
|
-
| `redis`
|
|
762
|
-
| `pg`
|
|
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
|
|
767
|
-
|
|
|
768
|
-
| `set` / `get` / `remove` / `flush`
|
|
769
|
-
| TTL (number or string; numeric strings = seconds) |
|
|
770
|
-
| Sliding TTL on read
|
|
771
|
-
| `removeNamespace`
|
|
772
|
-
| `onConflict: "create"` / `"update"`
|
|
773
|
-
| `increment` / `decrement` / `pull`
|
|
774
|
-
| `update()` / `merge()`
|
|
775
|
-
| List sub-API
|
|
776
|
-
| Tagged invalidation
|
|
777
|
-
| `similar()` / `set({ vector })`
|
|
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" } },
|
|
850
|
+
options: { pg: { table: "cache" } }, // static
|
|
850
851
|
});
|
|
851
852
|
|
|
852
|
-
await cache.use("pg", { client: pool });
|
|
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
|
|
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() {
|
|
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() {
|
|
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) {
|
|
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,
|
|
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);
|
|
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" },
|
|
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`);
|
|
1115
|
-
const bulk = await cache.increment(`post.${id}.views`, 10);
|
|
1116
|
-
const left = await cache.decrement(`stock.${sku}`, 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
|
|
1129
|
-
|
|
1130
|
-
| `redis`
|
|
1131
|
-
| `pg`
|
|
1132
|
-
| `memory` / `memoryExtended` / `lru` / `mock` | one process
|
|
1133
|
-
| `file`
|
|
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
|
-
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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);
|
|
1240
|
-
await recent.unshift(priorityEvent);
|
|
1241
|
-
const tail = await recent.pop();
|
|
1242
|
-
const head = await recent.shift();
|
|
1243
|
-
const first10 = await recent.slice(0, 10);
|
|
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);
|
|
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);
|
|
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");
|
|
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);
|
|
1290
|
-
await audit.trim(0, 999);
|
|
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();
|
|
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();
|
|
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);
|
|
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
|
-
|
|
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
|
|
1414
|
-
|
|
1415
|
-
| `redis`
|
|
1416
|
-
| `pg`
|
|
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`
|
|
1419
|
-
| `null`
|
|
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);
|
|
1446
|
-
await chat.set("draft", d, { ttl: "1h" });
|
|
1447
|
-
await chat.tags(["unread"]).set("ping", p);
|
|
1448
|
-
await chat.clear();
|
|
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" });
|
|
1467
|
+
const typing = chat.namespace("typing", { ttl: "5s" }); // overrides parent ttl
|
|
1458
1468
|
|
|
1459
|
-
await typing.set("user.42", true);
|
|
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
|
|
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",
|
|
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");
|
|
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
|
-
|
|
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
|
|
1550
|
-
|
|
1557
|
+
| Driver | `similar()` |
|
|
1558
|
+
| ----------------------------------- | -------------------------------------------------- |
|
|
1551
1559
|
| `memory` / `memoryExtended` / `lru` | ✅ Brute force (O(N)) — dev only past ~10k entries |
|
|
1552
|
-
| `pg`
|
|
1553
|
-
| `pg`
|
|
1554
|
-
| `redis`
|
|
1555
|
-
| `file`
|
|
1556
|
-
| `null`
|
|
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
|
|
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;
|
|
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",
|
|
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
|
|
1658
|
-
|
|
|
1659
|
-
| The keys share a known prefix
|
|
1660
|
-
| The keys are spread across prefixes, tied by entity | Tags
|
|
1661
|
-
| Both apply
|
|
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
|
|
1672
|
-
|
|
|
1673
|
-
| `null`
|
|
1679
|
+
| Driver | Tag invalidation |
|
|
1680
|
+
| ----------------------------------- | :----------------------------------------------------------: |
|
|
1681
|
+
| `null` | noop |
|
|
1674
1682
|
| `memory` / `memoryExtended` / `lru` | ✓ (separate index store; eviction and expiry never touch it) |
|
|
1675
|
-
| `file`
|
|
1676
|
-
| `redis`
|
|
1677
|
-
| `pg`
|
|
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();
|
|
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;
|
|
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");
|
|
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
|
|
1790
|
-
|
|
|
1791
|
-
| "Add 1 to this counter"
|
|
1792
|
-
| "Change one field on a cached object"
|
|
1793
|
-
| "Read-decide-maybe-write, possibly remove" | `update()`
|
|
1794
|
-
| "Only set if missing"
|
|
1795
|
-
| "Only set if already exists"
|
|
1796
|
-
| "Read-then-delete atomically"
|
|
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);
|
|
1819
|
-
parseTtl("1h");
|
|
1820
|
-
parseTtl(Infinity);
|
|
1821
|
-
parseTtl(-5);
|
|
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);
|
|
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);
|
|
1828
|
-
resolveTtl(undefined, undefined, 1800);
|
|
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);
|
|
1840
|
-
normalizeToOptions("1h");
|
|
1841
|
-
normalizeToOptions({ tags: ["x"] });
|
|
1842
|
-
normalizeToRememberOptions("1h");
|
|
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");
|
|
1851
|
-
parseCacheKey({ 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"]);
|
|
1855
|
-
mergeTagSets(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"]);
|
|
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]);
|
|
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
|
|
1935
|
-
|
|
1936
|
-
| None
|
|
1937
|
-
| Any args
|
|
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();
|
|
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" },
|
|
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
|
|
2028
|
-
|
|
2029
|
-
| `now < freshTtl`
|
|
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`
|
|
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
|
|
2061
|
-
|
|
2062
|
-
| memory / memoryExtended / lru / file / mock | ✅ Full
|
|
2063
|
-
| redis
|
|
2064
|
-
| pg
|
|
2065
|
-
| null
|
|
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
|
-
|
|
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
|
|
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
|
|