@warlock.js/cache 4.0.171 → 4.1.1
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/README.md +85 -0
- package/cjs/index.cjs +4088 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/cache-manager.d.mts +314 -0
- package/esm/cache-manager.d.mts.map +1 -0
- package/esm/cache-manager.mjs +486 -0
- package/esm/cache-manager.mjs.map +1 -0
- package/esm/cached/auto-key.d.mts +25 -0
- package/esm/cached/auto-key.d.mts.map +1 -0
- package/esm/cached/auto-key.mjs +55 -0
- package/esm/cached/auto-key.mjs.map +1 -0
- package/esm/cached/cached.d.mts +54 -0
- package/esm/cached/cached.d.mts.map +1 -0
- package/esm/cached/cached.mjs +25 -0
- package/esm/cached/cached.mjs.map +1 -0
- package/esm/cached/index.d.mts +3 -0
- package/esm/cached/index.mjs +5 -0
- package/esm/cached/normalize-args.d.mts +51 -0
- package/esm/cached/normalize-args.d.mts.map +1 -0
- package/esm/cached/normalize-args.mjs +26 -0
- package/esm/cached/normalize-args.mjs.map +1 -0
- package/esm/drivers/base-cache-driver.d.mts +322 -0
- package/esm/drivers/base-cache-driver.d.mts.map +1 -0
- package/esm/drivers/base-cache-driver.mjs +522 -0
- package/esm/drivers/base-cache-driver.mjs.map +1 -0
- package/esm/drivers/file-cache-driver.d.mts +68 -0
- package/esm/drivers/file-cache-driver.d.mts.map +1 -0
- package/esm/drivers/file-cache-driver.mjs +174 -0
- package/esm/drivers/file-cache-driver.mjs.map +1 -0
- package/esm/drivers/index.d.mts +9 -0
- package/esm/drivers/index.mjs +11 -0
- package/esm/drivers/lru-memory-cache-driver.d.mts +136 -0
- package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -0
- package/esm/drivers/lru-memory-cache-driver.mjs +317 -0
- package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -0
- package/esm/drivers/memory-cache-driver.d.mts +112 -0
- package/esm/drivers/memory-cache-driver.d.mts.map +1 -0
- package/esm/drivers/memory-cache-driver.mjs +241 -0
- package/esm/drivers/memory-cache-driver.mjs.map +1 -0
- package/esm/drivers/memory-extended-cache-driver.d.mts +17 -0
- package/esm/drivers/memory-extended-cache-driver.d.mts.map +1 -0
- package/esm/drivers/memory-extended-cache-driver.mjs +34 -0
- package/esm/drivers/memory-extended-cache-driver.mjs.map +1 -0
- package/esm/drivers/mock-cache-driver.d.mts +137 -0
- package/esm/drivers/mock-cache-driver.d.mts.map +1 -0
- package/esm/drivers/mock-cache-driver.mjs +226 -0
- package/esm/drivers/mock-cache-driver.mjs.map +1 -0
- package/esm/drivers/null-cache-driver.d.mts +69 -0
- package/esm/drivers/null-cache-driver.d.mts.map +1 -0
- package/esm/drivers/null-cache-driver.mjs +92 -0
- package/esm/drivers/null-cache-driver.mjs.map +1 -0
- package/esm/drivers/pg-cache-driver.d.mts +148 -0
- package/esm/drivers/pg-cache-driver.d.mts.map +1 -0
- package/esm/drivers/pg-cache-driver.mjs +437 -0
- package/esm/drivers/pg-cache-driver.mjs.map +1 -0
- package/esm/drivers/redis-cache-driver.d.mts +86 -0
- package/esm/drivers/redis-cache-driver.d.mts.map +1 -0
- package/esm/drivers/redis-cache-driver.mjs +312 -0
- package/esm/drivers/redis-cache-driver.mjs.map +1 -0
- package/esm/index.d.mts +21 -0
- package/esm/index.mjs +24 -0
- package/esm/list/index.d.mts +1 -0
- package/esm/list/memory-cache-list.d.mts +77 -0
- package/esm/list/memory-cache-list.d.mts.map +1 -0
- package/esm/list/memory-cache-list.mjs +119 -0
- package/esm/list/memory-cache-list.mjs.map +1 -0
- package/esm/metrics.d.mts +118 -0
- package/esm/metrics.d.mts.map +1 -0
- package/esm/metrics.mjs +197 -0
- package/esm/metrics.mjs.map +1 -0
- package/esm/scoped-cache.d.mts +205 -0
- package/esm/scoped-cache.d.mts.map +1 -0
- package/esm/scoped-cache.mjs +274 -0
- package/esm/scoped-cache.mjs.map +1 -0
- package/esm/tagged-cache.d.mts +89 -0
- package/esm/tagged-cache.d.mts.map +1 -0
- package/esm/tagged-cache.mjs +147 -0
- package/esm/tagged-cache.mjs.map +1 -0
- package/esm/tagged-scoped-cache.d.mts +111 -0
- package/esm/tagged-scoped-cache.d.mts.map +1 -0
- package/esm/tagged-scoped-cache.mjs +142 -0
- package/esm/tagged-scoped-cache.mjs.map +1 -0
- package/esm/types.d.mts +1067 -0
- package/esm/types.d.mts.map +1 -0
- package/esm/types.mjs +62 -0
- package/esm/types.mjs.map +1 -0
- package/esm/utils.d.mts +161 -0
- package/esm/utils.d.mts.map +1 -0
- package/esm/utils.mjs +222 -0
- package/esm/utils.mjs.map +1 -0
- package/llms-full.txt +2071 -0
- package/llms.txt +28 -0
- package/package.json +53 -39
- package/skills/apply-cache-patterns/SKILL.md +97 -0
- package/skills/cache-basics/SKILL.md +121 -0
- package/skills/configure-pg-cache/SKILL.md +115 -0
- package/skills/configure-set-options/SKILL.md +96 -0
- package/skills/handle-cache-errors/SKILL.md +91 -0
- package/skills/observe-cache/SKILL.md +103 -0
- package/skills/overview/SKILL.md +69 -0
- package/skills/pick-cache-driver/SKILL.md +115 -0
- package/skills/test-cache-code/SKILL.md +219 -0
- package/skills/use-cache-atomic/SKILL.md +67 -0
- package/skills/use-cache-bulk/SKILL.md +57 -0
- package/skills/use-cache-list/SKILL.md +85 -0
- package/skills/use-cache-lock/SKILL.md +104 -0
- package/skills/use-cache-namespace/SKILL.md +88 -0
- package/skills/use-cache-similarity/SKILL.md +94 -0
- package/skills/use-cache-tags/SKILL.md +85 -0
- package/skills/use-cache-update-merge/SKILL.md +84 -0
- package/skills/use-cache-utils/SKILL.md +89 -0
- package/skills/use-cached-hof/SKILL.md +102 -0
- package/skills/use-swr/SKILL.md +104 -0
- package/cjs/cache-manager.d.ts +0 -163
- package/cjs/cache-manager.d.ts.map +0 -1
- package/cjs/cache-manager.js +0 -322
- package/cjs/cache-manager.js.map +0 -1
- package/cjs/drivers/base-cache-driver.d.ts +0 -152
- package/cjs/drivers/base-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/base-cache-driver.js +0 -321
- package/cjs/drivers/base-cache-driver.js.map +0 -1
- package/cjs/drivers/file-cache-driver.d.ts +0 -45
- package/cjs/drivers/file-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/file-cache-driver.js +0 -133
- package/cjs/drivers/file-cache-driver.js.map +0 -1
- package/cjs/drivers/index.d.ts +0 -8
- package/cjs/drivers/index.d.ts.map +0 -1
- package/cjs/drivers/lru-memory-cache-driver.d.ts +0 -98
- package/cjs/drivers/lru-memory-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/lru-memory-cache-driver.js +0 -252
- package/cjs/drivers/lru-memory-cache-driver.js.map +0 -1
- package/cjs/drivers/memory-cache-driver.d.ts +0 -82
- package/cjs/drivers/memory-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/memory-cache-driver.js +0 -218
- package/cjs/drivers/memory-cache-driver.js.map +0 -1
- package/cjs/drivers/memory-extended-cache-driver.d.ts +0 -13
- package/cjs/drivers/memory-extended-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/memory-extended-cache-driver.js +0 -25
- package/cjs/drivers/memory-extended-cache-driver.js.map +0 -1
- package/cjs/drivers/null-cache-driver.d.ts +0 -58
- package/cjs/drivers/null-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/null-cache-driver.js +0 -84
- package/cjs/drivers/null-cache-driver.js.map +0 -1
- package/cjs/drivers/redis-cache-driver.d.ts +0 -57
- package/cjs/drivers/redis-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/redis-cache-driver.js +0 -263
- package/cjs/drivers/redis-cache-driver.js.map +0 -1
- package/cjs/index.d.ts +0 -6
- package/cjs/index.d.ts.map +0 -1
- package/cjs/index.js +0 -1
- package/cjs/index.js.map +0 -1
- package/cjs/tagged-cache.d.ts +0 -77
- package/cjs/tagged-cache.d.ts.map +0 -1
- package/cjs/tagged-cache.js +0 -160
- package/cjs/tagged-cache.js.map +0 -1
- package/cjs/types.d.ts +0 -391
- package/cjs/types.d.ts.map +0 -1
- package/cjs/types.js +0 -36
- package/cjs/types.js.map +0 -1
- package/cjs/utils.d.ts +0 -50
- package/cjs/utils.d.ts.map +0 -1
- package/cjs/utils.js +0 -55
- package/cjs/utils.js.map +0 -1
- package/esm/cache-manager.d.ts +0 -163
- package/esm/cache-manager.d.ts.map +0 -1
- package/esm/cache-manager.js +0 -322
- package/esm/cache-manager.js.map +0 -1
- package/esm/drivers/base-cache-driver.d.ts +0 -152
- package/esm/drivers/base-cache-driver.d.ts.map +0 -1
- package/esm/drivers/base-cache-driver.js +0 -321
- package/esm/drivers/base-cache-driver.js.map +0 -1
- package/esm/drivers/file-cache-driver.d.ts +0 -45
- package/esm/drivers/file-cache-driver.d.ts.map +0 -1
- package/esm/drivers/file-cache-driver.js +0 -133
- package/esm/drivers/file-cache-driver.js.map +0 -1
- package/esm/drivers/index.d.ts +0 -8
- package/esm/drivers/index.d.ts.map +0 -1
- package/esm/drivers/lru-memory-cache-driver.d.ts +0 -98
- package/esm/drivers/lru-memory-cache-driver.d.ts.map +0 -1
- package/esm/drivers/lru-memory-cache-driver.js +0 -252
- package/esm/drivers/lru-memory-cache-driver.js.map +0 -1
- package/esm/drivers/memory-cache-driver.d.ts +0 -82
- package/esm/drivers/memory-cache-driver.d.ts.map +0 -1
- package/esm/drivers/memory-cache-driver.js +0 -218
- package/esm/drivers/memory-cache-driver.js.map +0 -1
- package/esm/drivers/memory-extended-cache-driver.d.ts +0 -13
- package/esm/drivers/memory-extended-cache-driver.d.ts.map +0 -1
- package/esm/drivers/memory-extended-cache-driver.js +0 -25
- package/esm/drivers/memory-extended-cache-driver.js.map +0 -1
- package/esm/drivers/null-cache-driver.d.ts +0 -58
- package/esm/drivers/null-cache-driver.d.ts.map +0 -1
- package/esm/drivers/null-cache-driver.js +0 -84
- package/esm/drivers/null-cache-driver.js.map +0 -1
- package/esm/drivers/redis-cache-driver.d.ts +0 -57
- package/esm/drivers/redis-cache-driver.d.ts.map +0 -1
- package/esm/drivers/redis-cache-driver.js +0 -263
- package/esm/drivers/redis-cache-driver.js.map +0 -1
- package/esm/index.d.ts +0 -6
- package/esm/index.d.ts.map +0 -1
- package/esm/index.js +0 -1
- package/esm/index.js.map +0 -1
- package/esm/tagged-cache.d.ts +0 -77
- package/esm/tagged-cache.d.ts.map +0 -1
- package/esm/tagged-cache.js +0 -160
- package/esm/tagged-cache.js.map +0 -1
- package/esm/types.d.ts +0 -391
- package/esm/types.d.ts.map +0 -1
- package/esm/types.js +0 -36
- package/esm/types.js.map +0 -1
- package/esm/utils.d.ts +0 -50
- package/esm/utils.d.ts.map +0 -1
- package/esm/utils.js +0 -55
- package/esm/utils.js.map +0 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-cache-atomic
|
|
3
|
+
description: 'Atomic counters via cache.increment(key, by=1) / cache.decrement(key, by=1) — returns the new number, throws on non-numeric values. Triggers: `cache.increment`, `cache.decrement`, "view counter", "page views", "atomic counter", "decrement stock", "rate-limit counter", "INCRBY"; typical import `import { cache } from "@warlock.js/cache"`. Skip: read-modify-write of objects — `@warlock.js/cache/use-cache-update-merge/SKILL.md`; named-lock coordination — `@warlock.js/cache/use-cache-lock/SKILL.md`; competing libs `ioredis` `INCR`, native counters in a `Map`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Atomic counters — `cache.increment` / `cache.decrement`
|
|
7
|
+
|
|
8
|
+
Numeric counters that go up and down without a read-then-write race in your own
|
|
9
|
+
code. Both return the **new** value after the operation.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { cache } from "@warlock.js/cache";
|
|
13
|
+
|
|
14
|
+
const views = await cache.increment(`post.${id}.views`); // +1 → 1, 2, 3…
|
|
15
|
+
const bulk = await cache.increment(`post.${id}.views`, 10); // +10
|
|
16
|
+
const left = await cache.decrement(`stock.${sku}`, 3); // -3
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- A missing key is treated as `0`, so the first `increment` returns `by` (default `1`).
|
|
20
|
+
- `decrement(key, n)` is exactly `increment(key, -n)`.
|
|
21
|
+
- The stored value must be numeric — incrementing a string/object throws:
|
|
22
|
+
`Error: Cannot increment non-numeric value for key: <key>`.
|
|
23
|
+
|
|
24
|
+
## Atomicity is per-driver
|
|
25
|
+
|
|
26
|
+
| Driver | Guarantee |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `redis` | Native `INCRBY` / `DECRBY` — atomic **across processes/nodes** |
|
|
29
|
+
| memory family / `file` / `pg` | Read-modify-write — atomic **within one process** only |
|
|
30
|
+
|
|
31
|
+
For a counter that multiple instances bump concurrently (a global rate limit, a
|
|
32
|
+
shared tally), use the [`redis`](@warlock.js/cache/pick-cache-driver/SKILL.md)
|
|
33
|
+
driver. In-memory counters are fine for single-node work.
|
|
34
|
+
|
|
35
|
+
## TTL behavior differs too
|
|
36
|
+
|
|
37
|
+
This is the gotcha to remember:
|
|
38
|
+
|
|
39
|
+
- **Redis** `INCRBY` **preserves** the key's existing TTL.
|
|
40
|
+
- **Memory-family / pg** write the new value through `set()` with the driver's
|
|
41
|
+
**default** TTL — they do **not** carry over the previous entry's remaining TTL.
|
|
42
|
+
|
|
43
|
+
So if you need a counter that expires (a fixed window), set the TTL explicitly
|
|
44
|
+
when you create it and don't rely on `increment` to keep a window alive on the
|
|
45
|
+
in-memory drivers. For a value that should keep its TTL across edits, reach for
|
|
46
|
+
[`cache.update`](@warlock.js/cache/use-cache-update-merge/SKILL.md), which
|
|
47
|
+
preserves the remaining TTL.
|
|
48
|
+
|
|
49
|
+
## Common shapes
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// View counter
|
|
53
|
+
await cache.increment(`post.${id}.views`);
|
|
54
|
+
|
|
55
|
+
// Decrement stock, guard against oversell
|
|
56
|
+
const remaining = await cache.decrement(`stock.${sku}`, qty);
|
|
57
|
+
if (remaining < 0) {
|
|
58
|
+
await cache.increment(`stock.${sku}`, qty); // roll back
|
|
59
|
+
throw new Error("Out of stock");
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## See also
|
|
64
|
+
|
|
65
|
+
- [`@warlock.js/cache/use-cache-update-merge/SKILL.md`](@warlock.js/cache/use-cache-update-merge/SKILL.md) — atomic read-modify-write for objects, TTL-preserving
|
|
66
|
+
- [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md) — coordinate multi-step critical sections
|
|
67
|
+
- [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — when you need cross-node atomicity
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-cache-bulk
|
|
3
|
+
description: 'Bulk reads/writes via cache.many(keys[]) → values[] (nulls for misses, order preserved) and cache.setMany(record, ttl?) → void. Triggers: `cache.many`, `cache.setMany`, "get multiple keys at once", "batch read cache", "warm the cache", "preload many keys", "mget", "mset"; typical import `import { cache } from "@warlock.js/cache"`. Skip: tag-based bulk invalidation — `@warlock.js/cache/use-cache-tags/SKILL.md`; single-key ops — `@warlock.js/cache/cache-basics/SKILL.md`; competing libs `ioredis` `MGET`/`MSET`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Bulk operations — `cache.many` / `cache.setMany`
|
|
7
|
+
|
|
8
|
+
Read or write a batch of keys in one call instead of awaiting them one at a time.
|
|
9
|
+
|
|
10
|
+
## Read many — `many(keys)`
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { cache } from "@warlock.js/cache";
|
|
14
|
+
|
|
15
|
+
const [alice, bob, carol] = await cache.many(["user.1", "user.2", "user.3"]);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- Returns an array **positionally aligned** with `keys`.
|
|
19
|
+
- Missing keys come back as `null` (same as `get`), so the result length always
|
|
20
|
+
equals the input length — zip them back together by index.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
const ids = [1, 2, 3];
|
|
24
|
+
const users = await cache.many(ids.map((id) => `user.${id}`));
|
|
25
|
+
|
|
26
|
+
const missingIds = ids.filter((_, index) => users[index] === null);
|
|
27
|
+
// fetch only the misses from the origin…
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Write many — `setMany(items, ttl?)`
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
await cache.setMany({
|
|
34
|
+
"user.1": alice,
|
|
35
|
+
"user.2": bob,
|
|
36
|
+
"user.3": carol,
|
|
37
|
+
}, 3600); // optional TTL (seconds) applied to every entry
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- Keys are the object keys; values are the object values.
|
|
41
|
+
- The optional second arg is a single TTL (seconds) applied to **all** entries —
|
|
42
|
+
there's no per-entry TTL or `tags` knob here. When you need tags or mixed TTLs,
|
|
43
|
+
loop with [`cache.set`](@warlock.js/cache/configure-set-options/SKILL.md) and
|
|
44
|
+
the rich options object instead.
|
|
45
|
+
|
|
46
|
+
## Performance note
|
|
47
|
+
|
|
48
|
+
On every driver these run their underlying `get`/`set` calls **concurrently**
|
|
49
|
+
(`Promise.all`) on the shared connection; on the memory family it's effectively
|
|
50
|
+
instant. There's no partial-failure handling — if one write rejects, the
|
|
51
|
+
returned promise rejects.
|
|
52
|
+
|
|
53
|
+
## See also
|
|
54
|
+
|
|
55
|
+
- [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md) — single-key `get` / `set` / `remember`
|
|
56
|
+
- [`@warlock.js/cache/configure-set-options/SKILL.md`](@warlock.js/cache/configure-set-options/SKILL.md) — per-entry TTL, tags, conflict policy
|
|
57
|
+
- [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md) — invalidate a batch by tag
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-cache-list
|
|
3
|
+
description: 'Ordered collections via cache.list<T>(key) — push / unshift / pop / shift / slice / all / length / trim / clear. Triggers: `cache.list`, `push`, `unshift`, `pop`, `shift`, `slice`, `trim`, `clear`; "job queue in cache", "keep most recent N events", "audit log buffer", "FIFO queue"; typical import `import { cache } from "@warlock.js/cache"`. Skip: locking around list writes — `@warlock.js/cache/use-cache-lock/SKILL.md`; competing libs `bullmq`, `bee-queue`, `bull`, `ioredis` `LPUSH`; native `Array.push`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lists — the `cache.list<T>(key)` sub-API
|
|
7
|
+
|
|
8
|
+
Dedicated accessor for ordered collections — queues, recent-N buffers, sliding windows. Keeps the flat `CacheDriver` contract lean while giving list-shaped data a typed, purpose-built surface.
|
|
9
|
+
|
|
10
|
+
## Shape
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const recent = cache.list<Event>("recent-events");
|
|
14
|
+
|
|
15
|
+
await recent.push(event); // append to tail — returns new length
|
|
16
|
+
await recent.unshift(priorityEvent); // prepend to head — returns new length
|
|
17
|
+
const tail = await recent.pop(); // remove + return tail
|
|
18
|
+
const head = await recent.shift(); // remove + return head
|
|
19
|
+
const first10 = await recent.slice(0, 10); // view — does not mutate
|
|
20
|
+
const all = await recent.all();
|
|
21
|
+
const count = await recent.length();
|
|
22
|
+
await recent.trim(0, 99); // keep only indices 0..99 inclusive
|
|
23
|
+
await recent.clear();
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Type safety
|
|
27
|
+
|
|
28
|
+
The generic flows through every method. Pass the element type at the accessor call, not on each method:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
type Event = { type: string; at: number };
|
|
32
|
+
const queue = cache.list<Event>("jobs:queue");
|
|
33
|
+
|
|
34
|
+
await queue.push({ type: "import", at: Date.now() }); // ✓
|
|
35
|
+
await queue.push("not an event" as never); // ✗ at the caller
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Current performance characteristics
|
|
39
|
+
|
|
40
|
+
The default implementation (used by memory, memoryExtended, LRU, file, **and redis today**) stores the entire list as a single cache entry and does read-mutate-write on every op. Correct for every driver, O(n) per op.
|
|
41
|
+
|
|
42
|
+
Redis-native `LPUSH` / `RPUSH` / `LRANGE` / `LTRIM` is planned for v2.1 (see `domains/cache/backlog.md`). Until then, treat Redis list ops as O(n) and avoid very large lists on Redis.
|
|
43
|
+
|
|
44
|
+
## Concurrency warning
|
|
45
|
+
|
|
46
|
+
List writes on memory / file / LRU drivers **race** when two callers push simultaneously — the default read-mutate-write loop has no lock. If you need safe concurrent list writes today, wrap pushes in a distributed-lock pattern (see [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md)) or use a single writer.
|
|
47
|
+
|
|
48
|
+
Single-process memory with a single writer (typical test / script usage) is fine.
|
|
49
|
+
|
|
50
|
+
## Empty-list cleanup
|
|
51
|
+
|
|
52
|
+
When a list becomes empty (e.g. after successive `pop()` / `shift()` / `trim(0, -1)`), the backing cache entry is **removed** — `cache.get(key)` returns `null`, not `[]`. This keeps the store from accumulating empty list entries.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
await recent.push("a");
|
|
56
|
+
await recent.pop();
|
|
57
|
+
await cache.get("recent-events"); // null, not []
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Typical recipes
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// Recent-N audit log
|
|
64
|
+
const audit = cache.list<AuditEntry>("audit:recent");
|
|
65
|
+
await audit.unshift(entry); // newest at head
|
|
66
|
+
await audit.trim(0, 999); // keep most-recent 1000
|
|
67
|
+
|
|
68
|
+
// Lightweight job queue (single-node)
|
|
69
|
+
const queue = cache.list<Job>("jobs:pending");
|
|
70
|
+
await queue.push(job);
|
|
71
|
+
const next = await queue.shift(); // FIFO
|
|
72
|
+
|
|
73
|
+
// Stack
|
|
74
|
+
const stack = cache.list<Frame>("stack");
|
|
75
|
+
await stack.push(frame);
|
|
76
|
+
const top = await stack.pop(); // LIFO
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## What lists are NOT for
|
|
80
|
+
|
|
81
|
+
- Unordered uniqueness — no native set today; use a plain object/Map in memory, or roll your own via `cache.get/set`.
|
|
82
|
+
- Hash / field maps — same; use individual keys with a shared prefix.
|
|
83
|
+
- Ordered top-N with scoring — no sorted-set analog today.
|
|
84
|
+
|
|
85
|
+
These are tracked as candidates for v3.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-cache-lock
|
|
3
|
+
description: 'Distributed lock via cache.lock(key, ttl, fn) — acquire, run fn, auto-release. Returns {acquired: true, value} or {acquired: false}. Triggers: `cache.lock`, `LockOutcome`, `acquired`, `owner`; "run cron on only one server", "idempotent webhook handler", "dedup payment processing", "lock a task across nodes"; typical import `import { cache } from "@warlock.js/cache"`. Skip: raw `onConflict: "create"` recipe — `@warlock.js/cache/apply-cache-patterns/SKILL.md`; memoization — `@warlock.js/cache/use-cached-hof/SKILL.md`; competing libs `redlock`, `async-mutex`, `proper-lockfile`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `cache.lock()` — distributed locks with auto-release
|
|
7
|
+
|
|
8
|
+
`cache.lock(key, ttl, fn)` acquires a distributed lock, runs `fn`, and auto-releases (even on throw). Built on `set({ onConflict: "create" })` — Redis-native where available, emulated elsewhere.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- A task should run on only **one server at a time** (cron jobs, imports, migrations).
|
|
13
|
+
- Idempotent webhook or payment processing — dedup across retries.
|
|
14
|
+
- Any time you'd otherwise write `try { … } finally { cache.remove(lockKey); }`.
|
|
15
|
+
|
|
16
|
+
**Not for memoization** — use [`cached()`](@warlock.js/cache/use-cached-hof/SKILL.md) or `cache.remember()`.
|
|
17
|
+
|
|
18
|
+
## Shape
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// Primary — positional TTL
|
|
22
|
+
await cache.lock(key, ttl, fn);
|
|
23
|
+
|
|
24
|
+
// With options — owner for debugging, per-call driver override
|
|
25
|
+
await cache.lock(key, { ttl, owner?, driver? }, fn);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**TTL is required.** Forgotten locks stay forever if the process crashes; the TTL is your safety net.
|
|
29
|
+
|
|
30
|
+
## Return shape — discriminated union
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
type LockOutcome<T> =
|
|
34
|
+
| { acquired: true; value: T }
|
|
35
|
+
| { acquired: false };
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Unambiguous even when `fn` returns `undefined`. Narrow with TS:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const outcome = await cache.lock("lock.x", "1m", async () => compute());
|
|
42
|
+
|
|
43
|
+
if (outcome.acquired) {
|
|
44
|
+
console.log(outcome.value); // typed
|
|
45
|
+
} else {
|
|
46
|
+
console.log("someone else is running");
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Recipes
|
|
51
|
+
|
|
52
|
+
### Cron on only one server
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
cron.daily("3am", () =>
|
|
56
|
+
cache.lock("lock.cleanup", "30m", () => db.cleanup()),
|
|
57
|
+
);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Idempotent webhook
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
app.post("/webhooks/stripe", async (req, res) => {
|
|
64
|
+
const outcome = await cache.lock(
|
|
65
|
+
`webhook.stripe.${req.body.id}`,
|
|
66
|
+
"24h",
|
|
67
|
+
() => processStripeEvent(req.body),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!outcome.acquired) {
|
|
71
|
+
return res.status(200).json({ status: "already-processed" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
res.status(200).json({ status: "processed" });
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Batch job with debug-friendly owner
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
await cache.lock(
|
|
82
|
+
`lock.report.${date}`,
|
|
83
|
+
{ ttl: "1h", owner: `worker.${process.env.HOSTNAME}` },
|
|
84
|
+
() => generateReport(date),
|
|
85
|
+
);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`await cache.get("lock.report.2026-04-24")` reveals which worker holds the lock.
|
|
89
|
+
|
|
90
|
+
## Driver behavior
|
|
91
|
+
|
|
92
|
+
| Driver | Cross-process safe? |
|
|
93
|
+
|--------|:-:|
|
|
94
|
+
| `redis` | ✅ Native `SET … NX EX` |
|
|
95
|
+
| `memory` / `memoryExtended` / `lru` | ❌ In-process only |
|
|
96
|
+
| `file` | ⚠️ Single-host only (races across hosts) |
|
|
97
|
+
| `null` | n/a — always "acquires" |
|
|
98
|
+
|
|
99
|
+
## Gotchas
|
|
100
|
+
|
|
101
|
+
- **Non-re-entrant in v1.** A recursive call for the same key gets `{ acquired: false }`.
|
|
102
|
+
- **Don't release inside `fn`.** `lock()` handles release in `finally`. Manual `cache.remove(lockKey)` inside `fn` would let another process jump in mid-work.
|
|
103
|
+
- **TTL shorter than `fn` runtime = race.** Pick a TTL with generous margin.
|
|
104
|
+
- **Cross-server requires Redis.** Memory / LRU drivers don't coordinate across processes.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-cache-namespace
|
|
3
|
+
description: 'Scope cache keys via cache.namespace(prefix, options?) — every key auto-prefixed, scope-level ttl / tags defaults, nested scopes, .clear() sugar. Triggers: `cache.namespace`, `cache.removeNamespace`, `clear`, `globalPrefix`; "scope cache keys under a prefix", "share TTL across a whole prefix", "drop every key under user.1", "nested cache scopes"; typical import `import { cache } from "@warlock.js/cache"`. Skip: tag-based bulk drop — `@warlock.js/cache/use-cache-tags/SKILL.md`; multi-tenant driver-level prefix — `@warlock.js/cache/pick-cache-driver/SKILL.md`; SWR — `@warlock.js/cache/use-swr/SKILL.md`; competing libs `keyv` namespaces.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Scoped caches — `cache.namespace(prefix, options?)`
|
|
7
|
+
|
|
8
|
+
When you'll touch the same prefix more than a couple of times, grab a scoped handle instead of repeating the prefix at every call site.
|
|
9
|
+
|
|
10
|
+
## Shape
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const chat = cache.namespace(`chats.${id}`, { ttl: "30d", tags: [`user.${userId}`] });
|
|
14
|
+
|
|
15
|
+
await chat.set("messages.10", msg); // chats.<id>.messages.10, 30d, user.<userId>
|
|
16
|
+
await chat.set("draft", d, { ttl: "1h" }); // per-call ttl wins
|
|
17
|
+
await chat.tags(["unread"]).set("ping", p); // tags merge: user.<userId> + unread
|
|
18
|
+
await chat.clear(); // sugar for removeNamespace
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Scopes are pure views — same connection, same driver, no extra state. Per-call options always win over scope defaults; tags merge additively.
|
|
22
|
+
|
|
23
|
+
## Nested scopes
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const chat = cache.namespace(`chats.${id}`, { ttl: "30d" });
|
|
27
|
+
const typing = chat.namespace("typing", { ttl: "5s" }); // overrides parent ttl
|
|
28
|
+
|
|
29
|
+
await typing.set("user.42", true); // chats.<id>.typing.user.42, 5s
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Nested scopes inherit defaults and can override. Tags accumulate.
|
|
33
|
+
|
|
34
|
+
## When to reach for it
|
|
35
|
+
|
|
36
|
+
- The prefix repeats more than 2–3 times.
|
|
37
|
+
- A whole prefix shares a TTL or tag policy.
|
|
38
|
+
- You want `.clear()` to read like the intent ("clear this chat") instead of `removeNamespace(...)` boilerplate.
|
|
39
|
+
|
|
40
|
+
Inline prefixes are still fine for one-off writes.
|
|
41
|
+
|
|
42
|
+
## Plain `removeNamespace` when you already have the prefix
|
|
43
|
+
|
|
44
|
+
When you *do* know the prefix string and don't need a scoped handle for repeated reads/writes:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
await cache.set("user:1:profile", profile);
|
|
48
|
+
await cache.set("user:1:prefs", prefs);
|
|
49
|
+
await cache.set("user:2:profile", otherProfile);
|
|
50
|
+
|
|
51
|
+
await cache.removeNamespace("user.1"); // drops both user:1 entries, keeps user:2
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Cheaper than tags (no reverse index to maintain). Every real driver supports it — memory family and `lru` by prefix-scan, `file` by directory, `redis`/`pg` by key/`LIKE` prefix; `null` no-ops. See [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md).
|
|
55
|
+
|
|
56
|
+
## Multi-tenant scoping at the driver level
|
|
57
|
+
|
|
58
|
+
Instead of every call passing a tenant prefix, attach `globalPrefix` to the driver config — see [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md). Function form runs per call.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
options: {
|
|
62
|
+
redis: {
|
|
63
|
+
url: "...",
|
|
64
|
+
globalPrefix: () => `tenant-${currentContext.tenantId}`,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## SWR + namespace
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
|
|
73
|
+
|
|
74
|
+
await feed.swr(
|
|
75
|
+
"home",
|
|
76
|
+
{ freshTtl: "30s", staleTtl: "10m", tags: ["computed"] },
|
|
77
|
+
() => buildHomeFeed(userId),
|
|
78
|
+
);
|
|
79
|
+
// stored at feed.<userId>.home, tagged [user.<userId>, computed]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Note: scope `ttl` defaults are NOT applied to SWR — `freshTtl` / `staleTtl` always come from the call site. See [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md).
|
|
83
|
+
|
|
84
|
+
## Things NOT to do
|
|
85
|
+
|
|
86
|
+
- Don't create a `cache.namespace(prefix)` for a single read/write — the boilerplate doesn't pay off until the prefix repeats. Inline `cache.set("prefix.foo", ...)` is fine.
|
|
87
|
+
- Don't expect `cache.namespace(prefix).clear()` to do anything on the `null` driver — `removeNamespace` no-ops there (it caches nothing).
|
|
88
|
+
- Don't mix prefix separators. The convention is `.` (dot) — pick one and stick with it across scopes so nested prefixes compose predictably.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-cache-similarity
|
|
3
|
+
description: 'Vector retrieval via cache.similar(vector, {topK, threshold?, tags?}) — index with set(k, v, {vector}), query nearest by cosine similarity. Triggers: `cache.similar`, `cache.set` with `vector`, `topK`, `threshold`, `tags`, `cosineSimilarity`; "build a semantic cache for an LLM", "RAG retrieval from cache", "nearest-neighbor over cached entries", "skip the LLM when a similar answer exists"; typical import `import { cache } from "@warlock.js/cache"`. Skip: pgvector setup specifics — `@warlock.js/cache/configure-pg-cache/SKILL.md`; competing libs `pinecone`, `weaviate`, `chromadb`, `lancedb`, `faiss-node`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `cache.similar()` — vector-based retrieval
|
|
7
|
+
|
|
8
|
+
`cache.similar(vector, { topK, threshold?, tags? })` returns stored entries closest to `vector` by cosine similarity, ordered descending by score. Same `set` / `get` model — different lookup function.
|
|
9
|
+
|
|
10
|
+
## Shape
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// Index on the way in.
|
|
14
|
+
await cache.set(key, value, { vector: number[], tags?, ttl? });
|
|
15
|
+
|
|
16
|
+
// Query.
|
|
17
|
+
const hits = await cache.similar<T>(queryVec, {
|
|
18
|
+
topK: number, // required
|
|
19
|
+
threshold?: number, // [0, 1]; hits below are dropped
|
|
20
|
+
tags?: string[], // narrow candidate pool by tag (union)
|
|
21
|
+
});
|
|
22
|
+
// hits: { key: string; value: T; score: number }[] // score in [0, 1]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Capability matrix
|
|
26
|
+
|
|
27
|
+
| Driver | `similar()` |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `memory` / `memoryExtended` / `lru` | ✅ Brute force (O(N)) — dev only past ~10k entries |
|
|
30
|
+
| `pg` *with* `options.pg.vector` | ✅ pgvector + HNSW/IVFFlat index |
|
|
31
|
+
| `pg` *without* `options.pg.vector` | ❌ Throws `CacheUnsupportedError` |
|
|
32
|
+
| `redis` | ❌ Throws (RediSearch on backlog) |
|
|
33
|
+
| `file` | ❌ Throws |
|
|
34
|
+
| `null` | Returns `[]` |
|
|
35
|
+
|
|
36
|
+
## Always-true facts
|
|
37
|
+
|
|
38
|
+
1. **Cache is embedding-agnostic.** Caller computes vectors. The cache stores and ranks; it doesn't call out to an embedder.
|
|
39
|
+
2. **Only entries written with `set({ vector })` show up.** A plain `set` adds the entry as KV — invisible to `similar()`.
|
|
40
|
+
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.
|
|
41
|
+
4. **Tag filter narrows the candidate pool *before* ranking** — union semantics (entry must carry at least one of the listed tags).
|
|
42
|
+
5. **Dimension mismatch throws `CacheConfigurationError`** at both `set({ vector })` and `similar()` time. Don't switch embedders without re-indexing.
|
|
43
|
+
6. **TTL + LRU eviction also drop the vector** — expired or evicted entries are invisible to `similar()`.
|
|
44
|
+
|
|
45
|
+
## Recipes
|
|
46
|
+
|
|
47
|
+
### Semantic cache for an LLM
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const queryVec = await embed(prompt);
|
|
51
|
+
const hits = await cache.similar<Answer>(queryVec, { topK: 1, threshold: 0.92 });
|
|
52
|
+
|
|
53
|
+
if (hits.length > 0) {
|
|
54
|
+
return hits[0].value; // skip the LLM call
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const answer = await llm.complete(prompt);
|
|
58
|
+
await cache.set(`q.${hash(prompt)}`, answer, {
|
|
59
|
+
vector: queryVec,
|
|
60
|
+
ttl: "30d",
|
|
61
|
+
tags: ["llm-cache"],
|
|
62
|
+
});
|
|
63
|
+
return answer;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Tag-narrowed RAG
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const hits = await cache.similar<Doc>(await embed(question), {
|
|
70
|
+
topK: 5,
|
|
71
|
+
threshold: 0.7,
|
|
72
|
+
tags: ["docs", `tenant.${tenantId}`],
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Production swap — same code, different driver
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// Dev:
|
|
80
|
+
options: { memory: { ttl: "1h" } }
|
|
81
|
+
|
|
82
|
+
// Prod — same set/similar calls; index now lives in pgvector:
|
|
83
|
+
options: { pg: { client: pool, vector: { dimensions: 1536 } } }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
See [`@warlock.js/cache/configure-pg-cache/SKILL.md`](@warlock.js/cache/configure-pg-cache/SKILL.md).
|
|
87
|
+
|
|
88
|
+
## Things NOT to do
|
|
89
|
+
|
|
90
|
+
- Don't use `cache.similar()` on a memory driver with 100k+ vectorized entries — it scales O(N) per query. Switch to `pg` with `vector` config.
|
|
91
|
+
- Don't pass an empty array as `vector` — `cosineSimilarity` throws `CacheConfigurationError`.
|
|
92
|
+
- Don't mix vector dimensions in the same driver — re-embed when models change.
|
|
93
|
+
- Don't expect `similar()` to surface a missing vector (`set` without the `vector` option). Plain KV entries stay out of the similarity index.
|
|
94
|
+
- Don't use `topK: 0` or negative — `pg` rejects with `CacheConfigurationError`; memory drivers return `[]` but it's a code smell.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-cache-tags
|
|
3
|
+
description: 'Tag-based invalidation — attach tags on write, then cache.tags([...]).invalidate() drops every key bound to any of those tags. Triggers: `cache.tags`, `invalidate`, `cache.set` with `tags`; "invalidate every key tagged users", "drop everything for tenant 42", "bulk cache invalidation without knowing keys", "tag a cached value"; typical import `import { cache } from "@warlock.js/cache"`. Skip: prefix-based drop — `@warlock.js/cache/use-cache-namespace/SKILL.md`; HOF memoization with tags — `@warlock.js/cache/use-cached-hof/SKILL.md`; SWR — `@warlock.js/cache/use-swr/SKILL.md`; competing libs `cache-manager` tags, Next.js `revalidateTag`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tag-based invalidation
|
|
7
|
+
|
|
8
|
+
Tags let you invalidate by a label rather than by enumerating every key. Use them when the set of keys to invalidate is not known ahead of time.
|
|
9
|
+
|
|
10
|
+
## Attach tags on write
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// Inline — terser when you know the tags up front
|
|
14
|
+
await cache.set("user:1:profile", profile, { tags: ["users", "tenant-42"] });
|
|
15
|
+
await cache.set("user:1:prefs", prefs, { tags: ["users", "tenant-42"] });
|
|
16
|
+
|
|
17
|
+
// Fluent — useful when you already have a tagged handle
|
|
18
|
+
const users = cache.tags(["users"]);
|
|
19
|
+
await users.set("user:1", user);
|
|
20
|
+
await users.set("user:2", otherUser);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Invalidate
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// Drop everything tagged "users"
|
|
27
|
+
await cache.tags(["users"]).invalidate();
|
|
28
|
+
|
|
29
|
+
// Multi-tag — matches either tag (union)
|
|
30
|
+
await cache.tags(["tenant-42"]).invalidate();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Multi-tag is **union** semantics: an entry is invalidated if it carries **at least one** of the listed tags.
|
|
34
|
+
|
|
35
|
+
## When to reach for tags vs namespaces
|
|
36
|
+
|
|
37
|
+
| Use case | Reach for |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| The keys share a known prefix | `cache.removeNamespace("prefix")` ([`use-cache-namespace`](@warlock.js/cache/use-cache-namespace/SKILL.md)) |
|
|
40
|
+
| The keys are spread across prefixes, tied by entity | Tags |
|
|
41
|
+
| Both apply | Tags — more flexible; cheap on most drivers |
|
|
42
|
+
|
|
43
|
+
Namespaces are cheaper (no reverse index). Tags are more powerful (any key can carry any tag).
|
|
44
|
+
|
|
45
|
+
## Inline tag semantics
|
|
46
|
+
|
|
47
|
+
Subsequent `set("user:1", ...)` with **no** `tags` leaves previous associations intact (the tag index still points to the key), but a `set(..., { tags: [...] })` adds to whatever index entries already exist rather than removing old tag bindings. Tag associations are additive at write-time.
|
|
48
|
+
|
|
49
|
+
## Driver behavior
|
|
50
|
+
|
|
51
|
+
| Driver | Tag invalidation |
|
|
52
|
+
| --- | :-: |
|
|
53
|
+
| `null` | noop |
|
|
54
|
+
| `memory` / `memoryExtended` / `lru` / `file` | ✓ (reverse index in driver state) |
|
|
55
|
+
| `redis` | ✓ |
|
|
56
|
+
| `pg` | ✓ native via `GIN(tags)` index |
|
|
57
|
+
|
|
58
|
+
## SWR with tags
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
await cache.swr(
|
|
62
|
+
`product.${id}`,
|
|
63
|
+
{ freshTtl: "1m", staleTtl: "1h", tags: ["products", `tenant.${tenantId}`] },
|
|
64
|
+
() => db.products.find(id),
|
|
65
|
+
);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Tags re-apply on every successful refresh — see [`@warlock.js/cache/use-swr/SKILL.md`](@warlock.js/cache/use-swr/SKILL.md).
|
|
69
|
+
|
|
70
|
+
## `cached()` HOF with tags
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const getUser = cached(fn, { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] });
|
|
74
|
+
const getPosts = cached(fn, { key: (u) => `posts.by.${u}`, ttl: "30m", tags: ["users", "posts"] });
|
|
75
|
+
|
|
76
|
+
await cache.tags(["users"]).invalidate(); // drops both wrappers' caches
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
See [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md).
|
|
80
|
+
|
|
81
|
+
## Things NOT to do
|
|
82
|
+
|
|
83
|
+
- Don't tag aggressively. A reverse index per tag is cheap but not free — pick tags that actually correspond to invalidation events.
|
|
84
|
+
- Don't expect tag invalidation to fire events for each affected key. It's a bulk op; the event bus emits one `flushed` or per-key `removed` per driver implementation. Test by reading back, not by counting events.
|
|
85
|
+
- Don't use tags as a query mechanism. Tags drop keys; they don't list them. If you need "give me every user", store an index list separately.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-cache-update-merge
|
|
3
|
+
description: 'Atomic read-modify-write via cache.update(key, fn) (callback receives current) or cache.merge(key, partial) (shallow merge). Per-key chain lock serializes concurrent in-process callers. Triggers: `cache.update`, `cache.merge`, `cache.increment`, `cache.pull`; "atomically update a cached counter", "change one field on a cached object", "avoid get-spread-set race", "serialize concurrent cache writers"; typical import `import { cache } from "@warlock.js/cache"`. Skip: cross-process locking — `@warlock.js/cache/use-cache-lock/SKILL.md`; conditional create/update — `@warlock.js/cache/configure-set-options/SKILL.md`; competing libs `lodash.merge`, raw redis `WATCH`/`MULTI`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `update` and `merge` — atomic read-modify-write
|
|
7
|
+
|
|
8
|
+
Prefer these over the ad-hoc `get → spread → set` pattern. Each call takes a per-key chain lock so concurrent callers in the same process are serialized end-to-end.
|
|
9
|
+
|
|
10
|
+
## `update(key, fn, options?)`
|
|
11
|
+
|
|
12
|
+
Read the current value, pass it to `fn`, write what `fn` returns.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// Counter increment
|
|
16
|
+
await cache.update<number>("views", (current) => (current ?? 0) + 1);
|
|
17
|
+
|
|
18
|
+
// Update nested state with defaults
|
|
19
|
+
await cache.update<UserState>("user:1:state", (current) => ({
|
|
20
|
+
...(current ?? defaultState),
|
|
21
|
+
lastSeenAt: Date.now(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Conditional update — return null to remove
|
|
25
|
+
await cache.update<Session>("session:abc", (current) => {
|
|
26
|
+
if (!current || current.expired) {
|
|
27
|
+
return null; // removes the key
|
|
28
|
+
}
|
|
29
|
+
return { ...current, extendedAt: Date.now() };
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- `fn` receives `current: T | null`. Missing keys are `null`, not an exception.
|
|
34
|
+
- Returning `null` **removes** the entry.
|
|
35
|
+
- TTL is preserved by default. To reset, pass `{ ttl: "1h" }` as the 3rd arg.
|
|
36
|
+
|
|
37
|
+
## `merge(key, partial, options?)`
|
|
38
|
+
|
|
39
|
+
Shallow-merge sugar for the common "update one field" shape:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
await cache.merge<User>("user:1", { name: "Jane" });
|
|
43
|
+
await cache.merge<User>("user:1", { lastSeenAt: Date.now() }, { ttl: "1h" });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **Shallow only.** Arrays are replaced wholesale. Nested objects overwrite.
|
|
47
|
+
- Missing key → treats current as `{}`, creates with the partial.
|
|
48
|
+
- Preserves existing TTL unless the options override is passed.
|
|
49
|
+
|
|
50
|
+
Deep merge is not built in by design — too many edge cases with arrays and nullish values. If you need deep, write a custom `update(key, deepMerge(current, partial))`.
|
|
51
|
+
|
|
52
|
+
## What you can't do
|
|
53
|
+
|
|
54
|
+
- No JSONPath / dot-path partial updates (`update(key, "profile.name", "Jane")`). Use the callback form.
|
|
55
|
+
- No file-driver support — both methods throw `CacheUnsupportedError` there. Use memory or redis.
|
|
56
|
+
- No cross-process safety yet on Redis. The chain lock is in-process only. Cross-process safety requires `WATCH`/`MULTI` (tracked in `domains/cache/backlog.md` as a v2.1 follow-up). If two nodes run `update` on the same key simultaneously, last-write-wins.
|
|
57
|
+
|
|
58
|
+
## Concurrent-in-process correctness
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
await cache.set("counter", 0);
|
|
62
|
+
|
|
63
|
+
// 10 concurrent increments, all on the same key — all serialize
|
|
64
|
+
await Promise.all(
|
|
65
|
+
Array.from({ length: 10 }, () =>
|
|
66
|
+
cache.update<number>("counter", (current) => (current ?? 0) + 1),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await cache.get("counter"); // 10 — not a lost-update
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The per-key lock map lives on the driver instance and is cleared after each chain link finishes. No leak.
|
|
74
|
+
|
|
75
|
+
## When to reach for what
|
|
76
|
+
|
|
77
|
+
| Task | Use |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| "Add 1 to this counter" | `increment()` (Redis-atomic via `INCRBY`; in-process elsewhere) |
|
|
80
|
+
| "Change one field on a cached object" | `merge()` |
|
|
81
|
+
| "Read-decide-maybe-write, possibly remove" | `update()` |
|
|
82
|
+
| "Only set if missing" | `set(k, v, { onConflict: "create" })` |
|
|
83
|
+
| "Only set if already exists" | `set(k, v, { onConflict: "update" })` |
|
|
84
|
+
| "Read-then-delete atomically" | `pull()` |
|