@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/cjs/index.cjs +1439 -331
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/cache-manager.d.mts +4 -0
  5. package/esm/cache-manager.d.mts.map +1 -1
  6. package/esm/cache-manager.mjs +23 -5
  7. package/esm/cache-manager.mjs.map +1 -1
  8. package/esm/cached/auto-key.d.mts +14 -14
  9. package/esm/cached/auto-key.d.mts.map +1 -1
  10. package/esm/cached/auto-key.mjs +50 -34
  11. package/esm/cached/auto-key.mjs.map +1 -1
  12. package/esm/cached/cached.mjs +4 -0
  13. package/esm/cached/cached.mjs.map +1 -1
  14. package/esm/drivers/base-cache-driver.d.mts +84 -8
  15. package/esm/drivers/base-cache-driver.d.mts.map +1 -1
  16. package/esm/drivers/base-cache-driver.mjs +152 -42
  17. package/esm/drivers/base-cache-driver.mjs.map +1 -1
  18. package/esm/drivers/file-cache-driver.d.mts +15 -0
  19. package/esm/drivers/file-cache-driver.d.mts.map +1 -1
  20. package/esm/drivers/file-cache-driver.mjs +68 -18
  21. package/esm/drivers/file-cache-driver.mjs.map +1 -1
  22. package/esm/drivers/in-memory-tag-index.d.mts +44 -0
  23. package/esm/drivers/in-memory-tag-index.d.mts.map +1 -0
  24. package/esm/drivers/in-memory-tag-index.mjs +73 -0
  25. package/esm/drivers/in-memory-tag-index.mjs.map +1 -0
  26. package/esm/drivers/index.d.mts +1 -1
  27. package/esm/drivers/index.mjs +1 -1
  28. package/esm/drivers/lru-memory-cache-driver.d.mts +43 -0
  29. package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -1
  30. package/esm/drivers/lru-memory-cache-driver.mjs +96 -0
  31. package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -1
  32. package/esm/drivers/memory-cache-driver.d.mts +87 -30
  33. package/esm/drivers/memory-cache-driver.d.mts.map +1 -1
  34. package/esm/drivers/memory-cache-driver.mjs +214 -96
  35. package/esm/drivers/memory-cache-driver.mjs.map +1 -1
  36. package/esm/drivers/memory-extended-cache-driver.d.mts +9 -5
  37. package/esm/drivers/memory-extended-cache-driver.d.mts.map +1 -1
  38. package/esm/drivers/memory-extended-cache-driver.mjs +10 -23
  39. package/esm/drivers/memory-extended-cache-driver.mjs.map +1 -1
  40. package/esm/drivers/mock-cache-driver.d.mts +41 -0
  41. package/esm/drivers/mock-cache-driver.d.mts.map +1 -1
  42. package/esm/drivers/mock-cache-driver.mjs +105 -8
  43. package/esm/drivers/mock-cache-driver.mjs.map +1 -1
  44. package/esm/drivers/null-cache-driver.d.mts +8 -2
  45. package/esm/drivers/null-cache-driver.d.mts.map +1 -1
  46. package/esm/drivers/null-cache-driver.mjs +10 -2
  47. package/esm/drivers/null-cache-driver.mjs.map +1 -1
  48. package/esm/drivers/pg-cache-driver.d.mts +77 -3
  49. package/esm/drivers/pg-cache-driver.d.mts.map +1 -1
  50. package/esm/drivers/pg-cache-driver.mjs +229 -7
  51. package/esm/drivers/pg-cache-driver.mjs.map +1 -1
  52. package/esm/drivers/redis-cache-driver.d.mts +86 -1
  53. package/esm/drivers/redis-cache-driver.d.mts.map +1 -1
  54. package/esm/drivers/redis-cache-driver.mjs +252 -16
  55. package/esm/drivers/redis-cache-driver.mjs.map +1 -1
  56. package/esm/index.d.mts +2 -2
  57. package/esm/index.mjs +2 -2
  58. package/esm/list/memory-cache-list.d.mts +11 -8
  59. package/esm/list/memory-cache-list.d.mts.map +1 -1
  60. package/esm/list/memory-cache-list.mjs +39 -37
  61. package/esm/list/memory-cache-list.mjs.map +1 -1
  62. package/esm/scoped-cache.d.mts +9 -3
  63. package/esm/scoped-cache.d.mts.map +1 -1
  64. package/esm/scoped-cache.mjs +19 -7
  65. package/esm/scoped-cache.mjs.map +1 -1
  66. package/esm/tagged-cache.d.mts +20 -0
  67. package/esm/tagged-cache.d.mts.map +1 -1
  68. package/esm/tagged-cache.mjs +62 -24
  69. package/esm/tagged-cache.mjs.map +1 -1
  70. package/esm/tagged-scoped-cache.d.mts +7 -3
  71. package/esm/tagged-scoped-cache.d.mts.map +1 -1
  72. package/esm/tagged-scoped-cache.mjs +19 -7
  73. package/esm/tagged-scoped-cache.mjs.map +1 -1
  74. package/esm/types.d.mts +41 -3
  75. package/esm/types.d.mts.map +1 -1
  76. package/esm/types.mjs.map +1 -1
  77. package/esm/utils.d.mts +9 -3
  78. package/esm/utils.d.mts.map +1 -1
  79. package/esm/utils.mjs +16 -6
  80. package/esm/utils.mjs.map +1 -1
  81. package/llms-full.txt +1685 -1666
  82. package/llms.txt +18 -18
  83. package/package.json +4 -4
  84. package/skills/apply-cache-patterns/SKILL.md +98 -97
  85. package/skills/configure-pg-cache/SKILL.md +6 -5
  86. package/skills/configure-set-options/SKILL.md +102 -96
  87. package/skills/handle-cache-errors/SKILL.md +92 -92
  88. package/skills/index.md +20 -0
  89. package/skills/observe-cache/SKILL.md +103 -103
  90. package/skills/pick-cache-driver/SKILL.md +129 -123
  91. package/skills/test-cache-code/SKILL.md +219 -219
  92. package/skills/use-cache-atomic/SKILL.md +57 -65
  93. package/skills/use-cache-bulk/SKILL.md +57 -57
  94. package/skills/use-cache-list/SKILL.md +85 -85
  95. package/skills/use-cache-lock/SKILL.md +113 -104
  96. package/skills/use-cache-namespace/SKILL.md +88 -88
  97. package/skills/use-cache-similarity/SKILL.md +94 -94
  98. package/skills/use-cache-tags/SKILL.md +7 -4
  99. package/skills/use-cache-update-merge/SKILL.md +84 -84
  100. package/skills/use-cache-utils/SKILL.md +89 -89
  101. package/skills/use-cached-hof/SKILL.md +100 -99
  102. package/skills/use-swr/SKILL.md +104 -104
@@ -1,219 +1,219 @@
1
- ---
2
- name: test-cache-code
3
- description: "Testing code that touches cache in @warlock.js/cache; use when you need to test cache code."
4
- ---
5
-
6
- # Testing code that touches cache
7
-
8
- Three good strategies — pick based on what you're testing.
9
-
10
- ## Strategy 1 — `MockCacheDriver` for behavioral assertions (preferred)
11
-
12
- When you want to assert "did my service actually invalidate the cache after the update?" or "was `set` called with the right TTL?", reach for `MockCacheDriver`. It implements the full driver contract on a `Map` and adds three introspection helpers:
13
-
14
- - `wasCalled(operation, key?)` — was a given op invoked? Optional key matched post-`parseKey`.
15
- - `getStored(key)` — raw stored value, bypassing TTL handling and clone protection.
16
- - `reset()` — wipe storage, tag index, and call log in one call.
17
- - `callLog: CacheCall[]` — ordered record of every op (operation, parsed key, raw args, timestamp).
18
-
19
- ```ts
20
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
- import { cache, MockCacheDriver } from "@warlock.js/cache";
22
-
23
- describe("UserService.update", () => {
24
- let driver: MockCacheDriver;
25
-
26
- beforeEach(async () => {
27
- cache.setCacheConfigurations({
28
- default: "mock",
29
- logging: false,
30
- drivers: { mock: MockCacheDriver },
31
- options: { mock: {} },
32
- });
33
- await cache.init();
34
-
35
- driver = cache.currentDriver as MockCacheDriver;
36
- });
37
-
38
- afterEach(async () => {
39
- driver.reset();
40
- await cache.disconnect();
41
- });
42
-
43
- it("invalidates the user cache after update", async () => {
44
- await new UserService().update(42, { name: "Jane" });
45
-
46
- expect(driver.wasCalled("remove", "users.42")).toBe(true);
47
- });
48
-
49
- it("caches with the right TTL on read-through", async () => {
50
- await new UserService().getProfile(1);
51
-
52
- const setCall = driver.callLog.find((call) => call.operation === "set");
53
- expect(setCall?.args[1]).toBe("1h");
54
- });
55
- });
56
- ```
57
-
58
- `wasCalled` normalizes object keys, so `wasCalled("set", { id: 1 })` and `wasCalled("set", "id.1")` match the same call.
59
-
60
- ## Strategy 2 — `MemoryCacheDriver` for full-stack integration tests
61
-
62
- When you want the real read/write semantics (eviction, TTL expiry, similarity scoring) without the introspection ceremony, `MemoryCacheDriver` is the right pick. Same setup pattern as the mock — swap `MockCacheDriver` for `MemoryCacheDriver`.
63
-
64
- `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.
65
-
66
- ## Strategy 3 — `NullCacheDriver` when you need cache *off*
67
-
68
- Use `NullCacheDriver` to disable caching entirely for code paths that should still work without a cache (graceful-degradation tests):
69
-
70
- ```ts
71
- cache.setCacheConfigurations({
72
- default: "null",
73
- drivers: { null: NullCacheDriver },
74
- options: { null: {} },
75
- });
76
- await cache.init();
77
-
78
- // All cache ops no-op; get() always returns null; set() silently discards.
79
- ```
80
-
81
- ## Mocking Redis (for driver-level tests, not app code)
82
-
83
- For tests that specifically exercise `RedisCacheDriver`, use `vi.mock("redis")` with an in-memory fake. Example (condensed from `redis-cache-driver.spec.ts`):
84
-
85
- ```ts
86
- import { vi } from "vitest";
87
-
88
- class FakeRedisClient {
89
- public store = new Map<string, string>();
90
- private expires = new Map<string, number>();
91
- public on() { return this; }
92
- public async connect() {}
93
- public async quit() {}
94
- public async set(key: string, value: string, opts?: { EX?: number; NX?: boolean; XX?: boolean }) {
95
- if (opts?.NX && this.store.has(key)) return null;
96
- if (opts?.XX && !this.store.has(key)) return null;
97
- this.store.set(key, value);
98
- if (opts?.EX) this.expires.set(key, Date.now() + opts.EX * 1000);
99
- return "OK";
100
- }
101
- public async get(key: string) {
102
- const ttl = this.expires.get(key);
103
- if (ttl && ttl < Date.now()) {
104
- this.store.delete(key);
105
- return null;
106
- }
107
- return this.store.get(key) ?? null;
108
- }
109
- public async del(keys: string | string[]) {
110
- const arr = Array.isArray(keys) ? keys : [keys];
111
- let count = 0;
112
- for (const k of arr) if (this.store.delete(k)) count++;
113
- return count;
114
- }
115
- public async keys(pattern: string) {
116
- const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
117
- return [...this.store.keys()].filter((k) => regex.test(k));
118
- }
119
- public async flushAll() { this.store.clear(); this.expires.clear(); }
120
- public async incrBy(key: string, n: number) {
121
- const next = Number(this.store.get(key) ?? 0) + n;
122
- this.store.set(key, String(next));
123
- return next;
124
- }
125
- public async decrBy(key: string, n: number) { return this.incrBy(key, -n); }
126
- }
127
-
128
- const fakeClient = new FakeRedisClient();
129
- vi.mock("redis", () => ({ createClient: vi.fn(() => fakeClient) }));
130
- ```
131
-
132
- **One-time gotcha:** `RedisCacheDriver` kicks off an async `loadRedis()` at module import that resolves its internal "redis is available" flag. Before running any test that calls `connect()`, wait a short tick after the first dynamic import so the flag flips. The in-tree spec uses:
133
-
134
- ```ts
135
- async function importDriver() {
136
- const mod = await import("./redis-cache-driver");
137
- await new Promise((resolve) => setTimeout(resolve, 250));
138
- return mod.RedisCacheDriver;
139
- }
140
- ```
141
-
142
- ## Silencing cache logs in tests
143
-
144
- The in-package vitest config runs `silent: true`, but if you're outside the package, explicitly disable logging:
145
-
146
- ```ts
147
- cache.setCacheConfigurations({
148
- default: "memory",
149
- logging: false, // <-- here
150
- drivers: { memory: MemoryCacheDriver },
151
- options: { memory: {} },
152
- });
153
- ```
154
-
155
- Or per-driver: `driver.setLoggingState(false)`.
156
-
157
- ## Spying on events
158
-
159
- Every driver emits `hit`, `miss`, `set`, `removed`, `flushed`, `expired`. Attach listeners to assert cache behavior without inspecting internal state:
160
-
161
- ```ts
162
- const hits = vi.fn();
163
- cache.on("hit", hits);
164
-
165
- await service.getProfile("1");
166
- await service.getProfile("1");
167
-
168
- expect(hits).toHaveBeenCalledTimes(1); // second call was a hit
169
- ```
170
-
171
- Listeners registered via `cache.on(...)` automatically attach to any driver loaded later, so order of `on()` vs `init()` doesn't matter.
172
-
173
- ## Tests for `update` / `merge` concurrency
174
-
175
- The chain-serialization guarantee is worth testing when your code fans out concurrent updates:
176
-
177
- ```ts
178
- await cache.set("counter", 0);
179
-
180
- await Promise.all(
181
- Array.from({ length: 10 }, () =>
182
- cache.update<number>("counter", (c) => (c ?? 0) + 1),
183
- ),
184
- );
185
-
186
- await expect(cache.get("counter")).resolves.toBe(10);
187
- ```
188
-
189
- Runs in-process only — for cross-process safety see [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md).
190
-
191
- ## Testing similarity code paths
192
-
193
- `MemoryCacheDriver` runs `similar()` brute-force in-process — perfect for tests of code that calls `cache.similar(...)`. Use stable, hand-written vectors (don't call a real embedder in tests):
194
-
195
- ```ts
196
- beforeEach(async () => {
197
- cache.setCacheConfigurations({
198
- default: "memory",
199
- logging: false,
200
- drivers: { memory: MemoryCacheDriver },
201
- options: { memory: {} },
202
- });
203
- await cache.init();
204
- });
205
-
206
- it("returns the most similar doc above threshold", async () => {
207
- await cache.set("a", { text: "alpha" }, { vector: [1, 0, 0] });
208
- await cache.set("b", { text: "beta" }, { vector: [0, 1, 0] });
209
-
210
- const hits = await cache.similar([1, 0, 0], { topK: 1, threshold: 0.5 });
211
-
212
- expect(hits).toHaveLength(1);
213
- expect(hits[0].key).toBe("a");
214
- });
215
- ```
216
-
217
- ## Testing the `pg` driver without a real Postgres
218
-
219
- The `pg` driver accepts any object satisfying `PgClientLike` (`{ query(text, values) }`). For unit tests, hand-roll a minimal Map-backed fake — see `src/drivers/pg-cache-driver.spec.ts` for a 60-line `FakePool` that recognizes the exact SQL shapes the driver issues. For integration coverage, gate a real-PG suite on a `POSTGRES_URL` env var and skip cleanly when unset.
1
+ ---
2
+ name: test-cache-code
3
+ description: 'Test code that touches cache — MockCacheDriver (behavioral assertions with wasCalled / callLog), MemoryCacheDriver (full-stack), NullCacheDriver (graceful-degradation). Triggers: `MockCacheDriver`, `MemoryCacheDriver`, `NullCacheDriver`, `wasCalled`, `callLog`, `getStored`, `reset`, `cache.on`; "assert cache was invalidated", "test code that uses cache.set", "mock cache in vitest", "test similarity without a real embedder", "stub the pg cache driver"; typical import `import { cache, MockCacheDriver, MemoryCacheDriver } from "@warlock.js/cache"`. Skip: real driver picks — `@warlock.js/cache/pick-cache-driver/SKILL.md`; competing libs `jest-mock`, `sinon`, `redis-mock`; native `vi.fn`.'
4
+ ---
5
+
6
+ # Testing code that touches cache
7
+
8
+ Three good strategies — pick based on what you're testing.
9
+
10
+ ## Strategy 1 — `MockCacheDriver` for behavioral assertions (preferred)
11
+
12
+ When you want to assert "did my service actually invalidate the cache after the update?" or "was `set` called with the right TTL?", reach for `MockCacheDriver`. It implements the full driver contract on a `Map` and adds three introspection helpers:
13
+
14
+ - `wasCalled(operation, key?)` — was a given op invoked? Optional key matched post-`parseKey`.
15
+ - `getStored(key)` — raw stored value, bypassing TTL handling and clone protection.
16
+ - `reset()` — wipe storage, tag index, and call log in one call.
17
+ - `callLog: CacheCall[]` — ordered record of every op (operation, parsed key, raw args, timestamp).
18
+
19
+ ```ts
20
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
+ import { cache, MockCacheDriver } from "@warlock.js/cache";
22
+
23
+ describe("UserService.update", () => {
24
+ let driver: MockCacheDriver;
25
+
26
+ beforeEach(async () => {
27
+ cache.setCacheConfigurations({
28
+ default: "mock",
29
+ logging: false,
30
+ drivers: { mock: MockCacheDriver },
31
+ options: { mock: {} },
32
+ });
33
+ await cache.init();
34
+
35
+ driver = cache.currentDriver as MockCacheDriver;
36
+ });
37
+
38
+ afterEach(async () => {
39
+ driver.reset();
40
+ await cache.disconnect();
41
+ });
42
+
43
+ it("invalidates the user cache after update", async () => {
44
+ await new UserService().update(42, { name: "Jane" });
45
+
46
+ expect(driver.wasCalled("remove", "users.42")).toBe(true);
47
+ });
48
+
49
+ it("caches with the right TTL on read-through", async () => {
50
+ await new UserService().getProfile(1);
51
+
52
+ const setCall = driver.callLog.find((call) => call.operation === "set");
53
+ expect(setCall?.args[1]).toBe("1h");
54
+ });
55
+ });
56
+ ```
57
+
58
+ `wasCalled` normalizes object keys, so `wasCalled("set", { id: 1 })` and `wasCalled("set", "id.1")` match the same call.
59
+
60
+ ## Strategy 2 — `MemoryCacheDriver` for full-stack integration tests
61
+
62
+ When you want the real read/write semantics (eviction, TTL expiry, similarity scoring) without the introspection ceremony, `MemoryCacheDriver` is the right pick. Same setup pattern as the mock — swap `MockCacheDriver` for `MemoryCacheDriver`.
63
+
64
+ `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.
65
+
66
+ ## Strategy 3 — `NullCacheDriver` when you need cache *off*
67
+
68
+ Use `NullCacheDriver` to disable caching entirely for code paths that should still work without a cache (graceful-degradation tests):
69
+
70
+ ```ts
71
+ cache.setCacheConfigurations({
72
+ default: "null",
73
+ drivers: { null: NullCacheDriver },
74
+ options: { null: {} },
75
+ });
76
+ await cache.init();
77
+
78
+ // All cache ops no-op; get() always returns null; set() silently discards.
79
+ ```
80
+
81
+ ## Mocking Redis (for driver-level tests, not app code)
82
+
83
+ For tests that specifically exercise `RedisCacheDriver`, use `vi.mock("redis")` with an in-memory fake. Example (condensed from `redis-cache-driver.spec.ts`):
84
+
85
+ ```ts
86
+ import { vi } from "vitest";
87
+
88
+ class FakeRedisClient {
89
+ public store = new Map<string, string>();
90
+ private expires = new Map<string, number>();
91
+ public on() { return this; }
92
+ public async connect() {}
93
+ public async quit() {}
94
+ public async set(key: string, value: string, opts?: { EX?: number; NX?: boolean; XX?: boolean }) {
95
+ if (opts?.NX && this.store.has(key)) return null;
96
+ if (opts?.XX && !this.store.has(key)) return null;
97
+ this.store.set(key, value);
98
+ if (opts?.EX) this.expires.set(key, Date.now() + opts.EX * 1000);
99
+ return "OK";
100
+ }
101
+ public async get(key: string) {
102
+ const ttl = this.expires.get(key);
103
+ if (ttl && ttl < Date.now()) {
104
+ this.store.delete(key);
105
+ return null;
106
+ }
107
+ return this.store.get(key) ?? null;
108
+ }
109
+ public async del(keys: string | string[]) {
110
+ const arr = Array.isArray(keys) ? keys : [keys];
111
+ let count = 0;
112
+ for (const k of arr) if (this.store.delete(k)) count++;
113
+ return count;
114
+ }
115
+ public async keys(pattern: string) {
116
+ const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
117
+ return [...this.store.keys()].filter((k) => regex.test(k));
118
+ }
119
+ public async flushAll() { this.store.clear(); this.expires.clear(); }
120
+ public async incrBy(key: string, n: number) {
121
+ const next = Number(this.store.get(key) ?? 0) + n;
122
+ this.store.set(key, String(next));
123
+ return next;
124
+ }
125
+ public async decrBy(key: string, n: number) { return this.incrBy(key, -n); }
126
+ }
127
+
128
+ const fakeClient = new FakeRedisClient();
129
+ vi.mock("redis", () => ({ createClient: vi.fn(() => fakeClient) }));
130
+ ```
131
+
132
+ **One-time gotcha:** `RedisCacheDriver` kicks off an async `loadRedis()` at module import that resolves its internal "redis is available" flag. Before running any test that calls `connect()`, wait a short tick after the first dynamic import so the flag flips. The in-tree spec uses:
133
+
134
+ ```ts
135
+ async function importDriver() {
136
+ const mod = await import("./redis-cache-driver");
137
+ await new Promise((resolve) => setTimeout(resolve, 250));
138
+ return mod.RedisCacheDriver;
139
+ }
140
+ ```
141
+
142
+ ## Silencing cache logs in tests
143
+
144
+ The in-package vitest config runs `silent: true`, but if you're outside the package, explicitly disable logging:
145
+
146
+ ```ts
147
+ cache.setCacheConfigurations({
148
+ default: "memory",
149
+ logging: false, // <-- here
150
+ drivers: { memory: MemoryCacheDriver },
151
+ options: { memory: {} },
152
+ });
153
+ ```
154
+
155
+ Or per-driver: `driver.setLoggingState(false)`.
156
+
157
+ ## Spying on events
158
+
159
+ Every driver emits `hit`, `miss`, `set`, `removed`, `flushed`, `expired`. Attach listeners to assert cache behavior without inspecting internal state:
160
+
161
+ ```ts
162
+ const hits = vi.fn();
163
+ cache.on("hit", hits);
164
+
165
+ await service.getProfile("1");
166
+ await service.getProfile("1");
167
+
168
+ expect(hits).toHaveBeenCalledTimes(1); // second call was a hit
169
+ ```
170
+
171
+ Listeners registered via `cache.on(...)` automatically attach to any driver loaded later, so order of `on()` vs `init()` doesn't matter.
172
+
173
+ ## Tests for `update` / `merge` concurrency
174
+
175
+ The chain-serialization guarantee is worth testing when your code fans out concurrent updates:
176
+
177
+ ```ts
178
+ await cache.set("counter", 0);
179
+
180
+ await Promise.all(
181
+ Array.from({ length: 10 }, () =>
182
+ cache.update<number>("counter", (c) => (c ?? 0) + 1),
183
+ ),
184
+ );
185
+
186
+ await expect(cache.get("counter")).resolves.toBe(10);
187
+ ```
188
+
189
+ Runs in-process only — for cross-process safety see [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md).
190
+
191
+ ## Testing similarity code paths
192
+
193
+ `MemoryCacheDriver` runs `similar()` brute-force in-process — perfect for tests of code that calls `cache.similar(...)`. Use stable, hand-written vectors (don't call a real embedder in tests):
194
+
195
+ ```ts
196
+ beforeEach(async () => {
197
+ cache.setCacheConfigurations({
198
+ default: "memory",
199
+ logging: false,
200
+ drivers: { memory: MemoryCacheDriver },
201
+ options: { memory: {} },
202
+ });
203
+ await cache.init();
204
+ });
205
+
206
+ it("returns the most similar doc above threshold", async () => {
207
+ await cache.set("a", { text: "alpha" }, { vector: [1, 0, 0] });
208
+ await cache.set("b", { text: "beta" }, { vector: [0, 1, 0] });
209
+
210
+ const hits = await cache.similar([1, 0, 0], { topK: 1, threshold: 0.5 });
211
+
212
+ expect(hits).toHaveLength(1);
213
+ expect(hits[0].key).toBe("a");
214
+ });
215
+ ```
216
+
217
+ ## Testing the `pg` driver without a real Postgres
218
+
219
+ The `pg` driver accepts any object satisfying `PgClientLike` (`{ query(text, values) }`). For unit tests, hand-roll a minimal Map-backed fake — see `src/drivers/pg-cache-driver.spec.ts` for a 60-line `FakePool` that recognizes the exact SQL shapes the driver issues. For integration coverage, gate a real-PG suite on a `POSTGRES_URL` env var and skip cleanly when unset.
@@ -1,67 +1,59 @@
1
- ---
2
- name: use-cache-atomic
3
- description: "Atomic counters — cache.increment / cache.decrement in @warlock.js/cache; use when you need to use cache atomic."
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
-
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
24
  ## Atomicity is per-driver
25
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
26
+ `increment`, `decrement`, `pull` and `update` are atomic, but only some drivers are atomic **across servers**:
27
+
28
+ | Driver | `increment` / `decrement` | `pull` | `update` / `merge` |
29
+ |---|---|---|---|
30
+ | `redis` | cross-server (`INCRBY`) | cross-server (`GETDEL`) | cross-server (Lua compare-and-set, retries) |
31
+ | `pg` | cross-server (one upsert) | cross-server (`DELETE … RETURNING`) | cross-server (compare-and-set, retries) |
32
+ | `memory` / `memoryExtended` / `lru` / `mock` | one process | one process | one process |
33
+ | `file` | one process | one process | not supported (throws) |
34
+
35
+ 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.
36
+
37
+ ## TTL is kept
38
+
39
+ Every driver keeps the key's **remaining TTL** across `increment`/`decrement`, so a fixed-window counter can't turn permanent. Set the TTL when you create the counter (`cache.set(key, 0, "1m")`, then `increment`). Use [`cache.update`](@warlock.js/cache/use-cache-update-merge/SKILL.md) for objects.
40
+
41
+ ## Common shapes
42
+
43
+ ```ts
44
+ // View counter
45
+ await cache.increment(`post.${id}.views`);
46
+
47
+ // Decrement stock, guard against oversell
48
+ const remaining = await cache.decrement(`stock.${sku}`, qty);
49
+ if (remaining < 0) {
50
+ await cache.increment(`stock.${sku}`, qty); // roll back
51
+ throw new Error("Out of stock");
52
+ }
53
+ ```
54
+
55
+ ## See also
56
+
57
+ - [`@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
58
+ - [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md) — coordinate multi-step critical sections
59
+ - [`@warlock.js/cache/pick-cache-driver/SKILL.md`](@warlock.js/cache/pick-cache-driver/SKILL.md) — when you need cross-node atomicity