@warlock.js/cache 5.19.1 → 5.21.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 +61 -1
- package/cjs/index.cjs +1439 -331
- package/cjs/index.cjs.map +1 -1
- package/esm/cache-manager.d.mts +4 -0
- package/esm/cache-manager.d.mts.map +1 -1
- package/esm/cache-manager.mjs +23 -5
- package/esm/cache-manager.mjs.map +1 -1
- package/esm/cached/auto-key.d.mts +14 -14
- package/esm/cached/auto-key.d.mts.map +1 -1
- package/esm/cached/auto-key.mjs +50 -34
- package/esm/cached/auto-key.mjs.map +1 -1
- package/esm/cached/cached.mjs +4 -0
- package/esm/cached/cached.mjs.map +1 -1
- package/esm/drivers/base-cache-driver.d.mts +84 -8
- package/esm/drivers/base-cache-driver.d.mts.map +1 -1
- package/esm/drivers/base-cache-driver.mjs +152 -42
- package/esm/drivers/base-cache-driver.mjs.map +1 -1
- package/esm/drivers/file-cache-driver.d.mts +15 -0
- package/esm/drivers/file-cache-driver.d.mts.map +1 -1
- package/esm/drivers/file-cache-driver.mjs +68 -18
- package/esm/drivers/file-cache-driver.mjs.map +1 -1
- package/esm/drivers/in-memory-tag-index.d.mts +44 -0
- package/esm/drivers/in-memory-tag-index.d.mts.map +1 -0
- package/esm/drivers/in-memory-tag-index.mjs +73 -0
- package/esm/drivers/in-memory-tag-index.mjs.map +1 -0
- package/esm/drivers/index.d.mts +1 -1
- package/esm/drivers/index.mjs +1 -1
- package/esm/drivers/lru-memory-cache-driver.d.mts +43 -0
- package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -1
- package/esm/drivers/lru-memory-cache-driver.mjs +96 -0
- package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -1
- package/esm/drivers/memory-cache-driver.d.mts +87 -30
- package/esm/drivers/memory-cache-driver.d.mts.map +1 -1
- package/esm/drivers/memory-cache-driver.mjs +214 -96
- package/esm/drivers/memory-cache-driver.mjs.map +1 -1
- package/esm/drivers/memory-extended-cache-driver.d.mts +9 -5
- package/esm/drivers/memory-extended-cache-driver.d.mts.map +1 -1
- package/esm/drivers/memory-extended-cache-driver.mjs +10 -23
- package/esm/drivers/memory-extended-cache-driver.mjs.map +1 -1
- package/esm/drivers/mock-cache-driver.d.mts +41 -0
- package/esm/drivers/mock-cache-driver.d.mts.map +1 -1
- package/esm/drivers/mock-cache-driver.mjs +105 -8
- package/esm/drivers/mock-cache-driver.mjs.map +1 -1
- package/esm/drivers/null-cache-driver.d.mts +8 -2
- package/esm/drivers/null-cache-driver.d.mts.map +1 -1
- package/esm/drivers/null-cache-driver.mjs +10 -2
- package/esm/drivers/null-cache-driver.mjs.map +1 -1
- package/esm/drivers/pg-cache-driver.d.mts +77 -3
- package/esm/drivers/pg-cache-driver.d.mts.map +1 -1
- package/esm/drivers/pg-cache-driver.mjs +229 -7
- package/esm/drivers/pg-cache-driver.mjs.map +1 -1
- package/esm/drivers/redis-cache-driver.d.mts +86 -1
- package/esm/drivers/redis-cache-driver.d.mts.map +1 -1
- package/esm/drivers/redis-cache-driver.mjs +252 -16
- package/esm/drivers/redis-cache-driver.mjs.map +1 -1
- package/esm/index.d.mts +2 -2
- package/esm/index.mjs +2 -2
- package/esm/list/memory-cache-list.d.mts +11 -8
- package/esm/list/memory-cache-list.d.mts.map +1 -1
- package/esm/list/memory-cache-list.mjs +39 -37
- package/esm/list/memory-cache-list.mjs.map +1 -1
- package/esm/scoped-cache.d.mts +9 -3
- package/esm/scoped-cache.d.mts.map +1 -1
- package/esm/scoped-cache.mjs +19 -7
- package/esm/scoped-cache.mjs.map +1 -1
- package/esm/tagged-cache.d.mts +20 -0
- package/esm/tagged-cache.d.mts.map +1 -1
- package/esm/tagged-cache.mjs +62 -24
- package/esm/tagged-cache.mjs.map +1 -1
- package/esm/tagged-scoped-cache.d.mts +7 -3
- package/esm/tagged-scoped-cache.d.mts.map +1 -1
- package/esm/tagged-scoped-cache.mjs +19 -7
- package/esm/tagged-scoped-cache.mjs.map +1 -1
- package/esm/types.d.mts +41 -3
- package/esm/types.d.mts.map +1 -1
- package/esm/types.mjs.map +1 -1
- package/esm/utils.d.mts +9 -3
- package/esm/utils.d.mts.map +1 -1
- package/esm/utils.mjs +16 -6
- package/esm/utils.mjs.map +1 -1
- package/llms-full.txt +1685 -1666
- package/llms.txt +18 -18
- package/package.json +4 -4
- package/skills/apply-cache-patterns/SKILL.md +98 -97
- package/skills/configure-pg-cache/SKILL.md +6 -5
- package/skills/configure-set-options/SKILL.md +102 -96
- package/skills/handle-cache-errors/SKILL.md +92 -92
- package/skills/index.md +20 -0
- package/skills/observe-cache/SKILL.md +103 -103
- package/skills/pick-cache-driver/SKILL.md +129 -123
- package/skills/test-cache-code/SKILL.md +219 -219
- package/skills/use-cache-atomic/SKILL.md +57 -65
- package/skills/use-cache-bulk/SKILL.md +57 -57
- package/skills/use-cache-list/SKILL.md +85 -85
- package/skills/use-cache-lock/SKILL.md +113 -104
- package/skills/use-cache-namespace/SKILL.md +88 -88
- package/skills/use-cache-similarity/SKILL.md +94 -94
- package/skills/use-cache-tags/SKILL.md +7 -4
- package/skills/use-cache-update-merge/SKILL.md +84 -84
- package/skills/use-cache-utils/SKILL.md +89 -89
- package/skills/use-cached-hof/SKILL.md +100 -99
- package/skills/use-swr/SKILL.md +104 -104
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: use-cache-namespace
|
|
3
|
-
description:
|
|
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 (including dotted sibling keys like `ns.a`), `redis` by non-blocking `SCAN` (not `KEYS`), `pg` by key/`LIKE` prefix; `null` no-ops. Namespace strings are treated as data, not glob/path input — see [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) for the file-path-containment and redis-glob-escaping details.
|
|
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.
|
|
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 (including dotted sibling keys like `ns.a`), `redis` by non-blocking `SCAN` (not `KEYS`), `pg` by key/`LIKE` prefix; `null` no-ops. Namespace strings are treated as data, not glob/path input — see [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) for the file-path-containment and redis-glob-escaping details.
|
|
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.
|
|
@@ -1,94 +1,94 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: use-cache-similarity
|
|
3
|
-
description:
|
|
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.
|
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: use-cache-tags
|
|
3
|
-
description:
|
|
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
4
|
---
|
|
5
5
|
|
|
6
6
|
# Tag-based invalidation
|
|
@@ -51,9 +51,12 @@ Subsequent `set("user:1", ...)` with **no** `tags` leaves previous associations
|
|
|
51
51
|
| Driver | Tag invalidation |
|
|
52
52
|
| --- | :-: |
|
|
53
53
|
| `null` | noop |
|
|
54
|
-
| `memory` / `memoryExtended` / `lru`
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
54
|
+
| `memory` / `memoryExtended` / `lru` | ✓ (separate index store; eviction and expiry never touch it) |
|
|
55
|
+
| `file` | ✓ (serialized index) |
|
|
56
|
+
| `redis` | ✓ (native SET inside the prefix) |
|
|
57
|
+
| `pg` | ✓ (JSON-array merge on the index row) |
|
|
58
|
+
|
|
59
|
+
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.
|
|
57
60
|
|
|
58
61
|
## SWR with tags
|
|
59
62
|
|
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: use-cache-update-merge
|
|
3
|
-
description:
|
|
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()` |
|
|
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()` |
|