@basaltkit/cache 1.3.0 → 1.4.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/README.md +100 -10
- package/dist/drivers/memory.d.ts +18 -0
- package/dist/drivers/memory.js +44 -1
- package/dist/drivers/redis.d.ts +6 -0
- package/dist/drivers/redis.js +45 -6
- package/dist/index.js +32 -8
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -207,28 +207,112 @@ const cacheB = new Cache(new RedisCacheDriver(redis))
|
|
|
207
207
|
|
|
208
208
|
#### `CacheOptions`
|
|
209
209
|
|
|
210
|
-
| Option | Type |
|
|
211
|
-
|
|
212
|
-
| `prefix` | `string` |
|
|
213
|
-
| `scope` | `(() => string \| undefined) \| null` |
|
|
214
|
-
| `onMissingScope` | `'global' \| 'error'` |
|
|
210
|
+
| Option | Type | Default | Purpose |
|
|
211
|
+
|---|---|---|---|
|
|
212
|
+
| `prefix` | `string` | `'basalt'` | Root prefix for all keys. Change it when two apps share one Redis, so each owns its own key space and `flush()` can't cross the boundary. |
|
|
213
|
+
| `scope` | `(() => string \| undefined) \| null` | reads `ctx().tenant.id` → `tenant:<id>` | Dynamic prefix segment, resolved on **every** operation. Pass `null` for a deliberate global cache; pass your own function to scope by something other than tenant (region, API version). |
|
|
214
|
+
| `onMissingScope` | `'global' \| 'error'` | `'error'` when tenancy is active, else `'global'` — see below | What a read/write does when `scope()` resolves `undefined`. `'global'` shares one namespace; `'error'` throws `MissingCacheScopeError`. |
|
|
215
|
+
| `now` | `() => number` | `Date.now` | Injectable clock (ms) for the stale-while-revalidate windows. For tests. |
|
|
216
|
+
|
|
217
|
+
A value of `undefined` is a real cached value, not a miss: `remember()` with a factory that returns `undefined` computes it **once** and serves the cached `undefined` afterwards. `get()` still reports its `fallback` for such an entry, because `get` cannot distinguish "cached undefined" from "absent".
|
|
218
|
+
|
|
219
|
+
`SwrOptions` (the object form of `remember`'s second argument):
|
|
220
|
+
|
|
221
|
+
| Field | Type | Purpose |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| `ttl` | `DurationInput` | How long the value stays **fresh** — served with no revalidation. |
|
|
224
|
+
| `staleFor` | `DurationInput` | Extra window after `ttl` in which a stale value is served instantly while one background revalidation runs. After `ttl + staleFor` the entry is hard-expired and the next read blocks on the factory. The driver TTL is set to `ttl + staleFor`. |
|
|
225
|
+
|
|
226
|
+
#### `onMissingScope` and the `tenancy:active` interaction
|
|
227
|
+
|
|
228
|
+
The `Cache` class on its own defaults `onMissingScope` to `'global'`. `cachePlugin` **overrides
|
|
229
|
+
that to `'error'`** when three things hold at once:
|
|
230
|
+
|
|
231
|
+
1. `@basaltkit/tenancy` is registered — it adds a `tenancy:active` marker to the container's
|
|
232
|
+
metadata at register time, and the cache plugin checks for it;
|
|
233
|
+
2. you did not pass `onMissingScope`; **and**
|
|
234
|
+
3. you did not pass a custom `scope`.
|
|
235
|
+
|
|
236
|
+
Any explicit `onMissingScope` or `scope` wins — the upgrade only fills a gap you left blank.
|
|
237
|
+
|
|
238
|
+
The reasoning: in a multi-tenant app, a cache operation that resolves no tenant is a bug almost
|
|
239
|
+
every time (a queue worker, a cron task, a startup hook — code running outside a request). Under
|
|
240
|
+
`'global'` it doesn't fail; it writes a per-tenant value into the shared namespace, where the
|
|
241
|
+
**next tenant reads it**. That is a cross-tenant data leak with no error and no log. Failing
|
|
242
|
+
closed turns it into a stack trace at the call site.
|
|
243
|
+
|
|
244
|
+
Single-tenant apps are untouched: no tenancy plugin, no marker, so the default stays `'global'`
|
|
245
|
+
and nothing changes.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// Multi-tenant: this now throws instead of poisoning the shared namespace.
|
|
249
|
+
cachePlugin({ driver: 'redis', url }) // + tenancyPlugin() registered → onMissingScope: 'error'
|
|
250
|
+
|
|
251
|
+
// Deliberate global cache — opt out explicitly, and the scope check never runs.
|
|
252
|
+
cachePlugin({ driver: 'redis', url, scope: null })
|
|
253
|
+
|
|
254
|
+
// Keep the old permissive behaviour, knowingly.
|
|
255
|
+
cachePlugin({ driver: 'redis', url, onMissingScope: 'global' })
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**`flush()` always fails closed**, whatever `onMissingScope` says: if `scope` is not `null` and
|
|
259
|
+
resolves `undefined`, it throws rather than wiping the whole prefix. A mis-scoped `flush()` under
|
|
260
|
+
`'global'` would delete **every tenant's** cache in one call, and no convenience is worth that.
|
|
261
|
+
`scope: null` is exempt — you declared the cache global, so its "everything" is genuinely
|
|
262
|
+
everything you meant.
|
|
263
|
+
|
|
264
|
+
To flush one tenant, call it inside that tenant's context; to flush the global namespace of a
|
|
265
|
+
tenant-scoped cache, build a second `Cache` with `scope: null`.
|
|
215
266
|
|
|
216
267
|
### `cachePlugin(options?: CachePluginOptions)`
|
|
217
268
|
|
|
218
269
|
Registers `Cache` in the container under the `CACHE` token and disconnects the driver on application `shutdown`.
|
|
219
270
|
|
|
271
|
+
#### Drivers
|
|
272
|
+
|
|
273
|
+
**`MemoryCacheDriver`** — the default, and **bounded**: cache keys usually embed ids, slugs or query fingerprints, so unbounded growth on user-influenced keys is an OOM vector. It holds at most `maxEntries` live entries (default **10 000**), evicting already-expired entries first and then the least recently used (`get` counts as a use).
|
|
274
|
+
|
|
275
|
+
| Option | Type | Default | Purpose |
|
|
276
|
+
|---|---|---|---|
|
|
277
|
+
| `maxEntries` | `number` | `10_000` | Live-entry cap before eviction; `Infinity` disables it. |
|
|
278
|
+
|
|
279
|
+
It also exposes `size` (live entry count) for diagnostics and tests.
|
|
280
|
+
|
|
281
|
+
**`RedisCacheDriver`** — values are `JSON.stringify`/`JSON.parse`'d, so store plain data (`Date` comes back as a string; `Map`/class instances do not survive). Two details worth knowing:
|
|
282
|
+
|
|
283
|
+
- **Prefix flushes are glob-escaped.** The scope segment carries user-controlled data (a tenant id or slug), so `flushPrefix` escapes Redis glob metacharacters (`\ * ? [ ] ^`) before `SCAN MATCH`. Without it, a tenant named `a*` would match — and delete — other tenants' keys.
|
|
284
|
+
- **Tag indexes are sorted sets** under `__tagz__:<tag>`, scored by each member's expiry, plus a reverse index `__tagsof__:<key>`. Expired members are pruned on write and `delete` unregisters the key from its tags, so the index tracks the live key set instead of growing forever. Upgrading from the older plain sets (`__tags__:`) leaves inert orphans — clear them once with `DEL __tags__:*`.
|
|
285
|
+
|
|
220
286
|
#### `CachePluginOptions` (extends `CacheOptions`)
|
|
221
287
|
|
|
222
|
-
| Option | Type |
|
|
223
|
-
|
|
224
|
-
| `driver` | `'memory' \| 'redis'
|
|
225
|
-
| `url` | `string` |
|
|
226
|
-
| `prefix`, `scope` | — |
|
|
288
|
+
| Option | Type | Default | Purpose |
|
|
289
|
+
|---|---|---|---|
|
|
290
|
+
| `driver` | `'memory' \| 'redis' \| CacheDriver` | `'memory'` | Built-in driver by name, **or a driver instance** — that is how you plug in [`@basaltkit/cache-tiered`](https://www.npmjs.com/package/@basaltkit/cache-tiered) or your own. An instance is used as-is and `url` is ignored. |
|
|
291
|
+
| `url` | `string` | — | Required with `driver: 'redis'`. Redis connection URL (e.g. `redis://localhost:6379`). |
|
|
292
|
+
| `prefix`, `scope`, `onMissingScope`, `now` | — | see `CacheOptions` | Inherited from `CacheOptions`. |
|
|
293
|
+
|
|
294
|
+
The plugin registers the `Cache` as a container singleton and `disconnect()`s the driver on
|
|
295
|
+
application `shutdown`.
|
|
227
296
|
|
|
228
297
|
### `CACHE`
|
|
229
298
|
|
|
230
299
|
Dependency injection token: `app.container.get(CACHE)` returns the `Cache` instance.
|
|
231
300
|
|
|
301
|
+
### Errors
|
|
302
|
+
|
|
303
|
+
| Error | Code | When |
|
|
304
|
+
|---|---|---|
|
|
305
|
+
| `MissingCacheScopeError` | `CACHE_SCOPE_MISSING` | A tenant-scoped cache resolved no tenant. Raised on a read/write when `onMissingScope: 'error'` (the default once tenancy is registered), and **always** on `flush()` regardless of `onMissingScope`. Extends `BasaltError`. The message names the operation and tells you to establish a tenant or pass `scope: null`. |
|
|
306
|
+
| `DURATION_INVALID` (from `@basaltkit/core`) | `DURATION_INVALID` | A TTL string that `parseDuration` doesn't understand. Accepted forms: a number of ms, or `'500ms'` / `'30s'` / `'5m'` / `'2h'` / `'7d'`. |
|
|
307
|
+
|
|
308
|
+
There are no other error classes: driver-level faults (a Redis connection error) propagate from
|
|
309
|
+
`ioredis` unchanged.
|
|
310
|
+
|
|
311
|
+
### Hooks & events
|
|
312
|
+
|
|
313
|
+
This package emits none. `onMissingScope` is a policy switch, not a callback, and there is no
|
|
314
|
+
hit/miss event bus — measure at the `remember` call site if you need cache metrics.
|
|
315
|
+
|
|
232
316
|
### `interface CacheDriver` (Advanced)
|
|
233
317
|
|
|
234
318
|
Contract that any driver must implement — implement it to create your own storage:
|
|
@@ -272,6 +356,12 @@ This is by design: `remember`'s deduplication is **per process** (it uses an in-
|
|
|
272
356
|
**`flush()` deleted less than I expected.**
|
|
273
357
|
`flush()` only deletes keys under the current prefix + scope (that's the safety guarantee: it never does `FLUSHALL` on Redis). To clear a tenant's keys, call `flush()` inside that tenant's context.
|
|
274
358
|
|
|
359
|
+
**`CACHE_SCOPE_MISSING` — "Refusing cache operation: a tenant-scoped cache resolved no tenant".**
|
|
360
|
+
Cache code ran outside a tenant context — usually a queue worker, a scheduled task, or a boot hook. Establish the tenant around the call (`runWithContext({ tenant: { id } }, …)`), or, if the value really is global, use a cache built with `scope: null`. Do **not** reach for `onMissingScope: 'global'` to make it go away: that is what makes one tenant's value readable by the next.
|
|
361
|
+
|
|
362
|
+
**`CACHE_SCOPE_MISSING` on `flush()` even though `onMissingScope` is `'global'`.**
|
|
363
|
+
Deliberate. `flush()` always fails closed, because a mis-scoped whole-namespace wipe would delete every tenant's cache. Only `scope: null` exempts it.
|
|
364
|
+
|
|
275
365
|
**I configured `driver: 'redis'` and the application fails to boot/use the cache.**
|
|
276
366
|
With `driver: 'redis'`, the `url` option is required. Also verify that the Redis server is reachable at that URL.
|
|
277
367
|
|
package/dist/drivers/memory.d.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import type { CacheDriver } from '../driver.js';
|
|
2
|
+
export interface MemoryCacheDriverOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Maximum number of live entries. Past it, expired entries are dropped first,
|
|
5
|
+
* then the least-recently-used ones. Default 10 000; `Infinity` disables the cap.
|
|
6
|
+
*/
|
|
7
|
+
maxEntries?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Bounded in-process cache with LRU eviction. `get` counts as a use, so hot keys
|
|
11
|
+
* survive; cold ones are evicted first.
|
|
12
|
+
*/
|
|
2
13
|
export declare class MemoryCacheDriver implements CacheDriver {
|
|
3
14
|
private readonly store;
|
|
15
|
+
private readonly maxEntries;
|
|
16
|
+
constructor(options?: MemoryCacheDriverOptions);
|
|
17
|
+
/** Live entry count — useful to assert the bound in tests and diagnostics. */
|
|
18
|
+
get size(): number;
|
|
4
19
|
get(key: string): Promise<unknown>;
|
|
5
20
|
set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void>;
|
|
6
21
|
delete(key: string): Promise<boolean>;
|
|
7
22
|
flushPrefix(prefix: string): Promise<void>;
|
|
8
23
|
flushTags(tags: string[]): Promise<void>;
|
|
9
24
|
disconnect(): Promise<void>;
|
|
25
|
+
private expired;
|
|
26
|
+
/** Reclaim space: expired entries first (free wins), then least-recently-used. */
|
|
27
|
+
private evict;
|
|
10
28
|
}
|
package/dist/drivers/memory.js
CHANGED
|
@@ -1,21 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many entries the in-process driver holds before it starts evicting.
|
|
3
|
+
* It is the DEFAULT driver, so it must be bounded: an unbounded map keyed by
|
|
4
|
+
* user-influenced strings (ids, slugs, query fingerprints) is an OOM vector.
|
|
5
|
+
* Raise it — or pass `maxEntries: Infinity` — if you deliberately want no cap.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_MAX_ENTRIES = 10_000;
|
|
8
|
+
/**
|
|
9
|
+
* Bounded in-process cache with LRU eviction. `get` counts as a use, so hot keys
|
|
10
|
+
* survive; cold ones are evicted first.
|
|
11
|
+
*/
|
|
1
12
|
export class MemoryCacheDriver {
|
|
2
13
|
store = new Map();
|
|
14
|
+
maxEntries;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
17
|
+
}
|
|
18
|
+
/** Live entry count — useful to assert the bound in tests and diagnostics. */
|
|
19
|
+
get size() {
|
|
20
|
+
return this.store.size;
|
|
21
|
+
}
|
|
3
22
|
async get(key) {
|
|
4
23
|
const entry = this.store.get(key);
|
|
5
24
|
if (!entry)
|
|
6
25
|
return undefined;
|
|
7
|
-
if (
|
|
26
|
+
if (this.expired(entry)) {
|
|
8
27
|
this.store.delete(key);
|
|
9
28
|
return undefined;
|
|
10
29
|
}
|
|
30
|
+
// Re-insert to move the key to the most-recently-used end of the Map.
|
|
31
|
+
this.store.delete(key);
|
|
32
|
+
this.store.set(key, entry);
|
|
11
33
|
return entry.value;
|
|
12
34
|
}
|
|
13
35
|
async set(key, value, ttlMs, tags = []) {
|
|
36
|
+
this.store.delete(key); // rewrite counts as a use → move to the MRU end
|
|
14
37
|
this.store.set(key, {
|
|
15
38
|
value,
|
|
16
39
|
...(ttlMs !== undefined ? { expiresAt: Date.now() + ttlMs } : {}),
|
|
17
40
|
tags: new Set(tags),
|
|
18
41
|
});
|
|
42
|
+
this.evict();
|
|
19
43
|
}
|
|
20
44
|
async delete(key) {
|
|
21
45
|
return this.store.delete(key);
|
|
@@ -35,4 +59,23 @@ export class MemoryCacheDriver {
|
|
|
35
59
|
async disconnect() {
|
|
36
60
|
this.store.clear();
|
|
37
61
|
}
|
|
62
|
+
expired(entry) {
|
|
63
|
+
return entry.expiresAt !== undefined && Date.now() >= entry.expiresAt;
|
|
64
|
+
}
|
|
65
|
+
/** Reclaim space: expired entries first (free wins), then least-recently-used. */
|
|
66
|
+
evict() {
|
|
67
|
+
if (this.store.size <= this.maxEntries)
|
|
68
|
+
return;
|
|
69
|
+
for (const [key, entry] of this.store) {
|
|
70
|
+
if (this.store.size <= this.maxEntries)
|
|
71
|
+
return;
|
|
72
|
+
if (this.expired(entry))
|
|
73
|
+
this.store.delete(key);
|
|
74
|
+
}
|
|
75
|
+
for (const key of this.store.keys()) {
|
|
76
|
+
if (this.store.size <= this.maxEntries)
|
|
77
|
+
return;
|
|
78
|
+
this.store.delete(key);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
38
81
|
}
|
package/dist/drivers/redis.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Redis } from 'ioredis';
|
|
2
2
|
import type { CacheDriver } from '../driver.js';
|
|
3
|
+
/**
|
|
4
|
+
* Escapes Redis glob metacharacters so a key segment is matched literally.
|
|
5
|
+
* Scope segments carry user-controlled data (a tenant id / slug); left raw, a
|
|
6
|
+
* tenant named `a*` would make `flushPrefix` match — and DELETE — other tenants' keys.
|
|
7
|
+
*/
|
|
8
|
+
export declare function escapeGlob(value: string): string;
|
|
3
9
|
export declare class RedisCacheDriver implements CacheDriver {
|
|
4
10
|
private readonly redis;
|
|
5
11
|
constructor(redis: Redis);
|
package/dist/drivers/redis.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { Redis } from 'ioredis';
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Namespace for tag indexes, outside the value key space. Each tag is a SORTED
|
|
4
|
+
* SET scoring every member by its expiry timestamp (`+inf` for entries with no
|
|
5
|
+
* TTL), which lets expired members be pruned in O(log n) — a plain SET grew
|
|
6
|
+
* forever, since Redis never tells us that a member key has expired.
|
|
7
|
+
*
|
|
8
|
+
* NOTE the namespace changed from the pre-1.x `__tags__:` plain sets. Old sets
|
|
9
|
+
* are inert orphans; clear them once with `DEL __tags__:*` after upgrading.
|
|
10
|
+
*/
|
|
11
|
+
const TAG_PREFIX = '__tagz__:';
|
|
12
|
+
/** Reverse index (key → its tags), so `delete` can unregister the key from its tags. */
|
|
13
|
+
const TAGS_OF_PREFIX = '__tagsof__:';
|
|
14
|
+
/**
|
|
15
|
+
* Escapes Redis glob metacharacters so a key segment is matched literally.
|
|
16
|
+
* Scope segments carry user-controlled data (a tenant id / slug); left raw, a
|
|
17
|
+
* tenant named `a*` would make `flushPrefix` match — and DELETE — other tenants' keys.
|
|
18
|
+
*/
|
|
19
|
+
export function escapeGlob(value) {
|
|
20
|
+
return value.replace(/[\\*?[\]^]/g, (c) => `\\${c}`);
|
|
21
|
+
}
|
|
4
22
|
export class RedisCacheDriver {
|
|
5
23
|
redis;
|
|
6
24
|
constructor(redis) {
|
|
@@ -21,17 +39,36 @@ export class RedisCacheDriver {
|
|
|
21
39
|
else {
|
|
22
40
|
await this.redis.set(key, raw);
|
|
23
41
|
}
|
|
42
|
+
if (tags.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
const score = ttlMs === undefined ? Number.POSITIVE_INFINITY : Date.now() + ttlMs;
|
|
45
|
+
const now = Date.now();
|
|
24
46
|
for (const tag of tags) {
|
|
25
|
-
|
|
47
|
+
const tagKey = TAG_PREFIX + tag;
|
|
48
|
+
// Drop members that have already expired before adding the new one: the
|
|
49
|
+
// index stays proportional to the LIVE key set instead of growing forever.
|
|
50
|
+
await this.redis.zremrangebyscore(tagKey, '-inf', `(${now}`);
|
|
51
|
+
await this.redis.zadd(tagKey, score === Number.POSITIVE_INFINITY ? '+inf' : String(score), key);
|
|
26
52
|
}
|
|
53
|
+
const reverseKey = TAGS_OF_PREFIX + key;
|
|
54
|
+
await this.redis.sadd(reverseKey, ...tags);
|
|
55
|
+
if (ttlMs !== undefined)
|
|
56
|
+
await this.redis.pexpire(reverseKey, Math.max(1, Math.ceil(ttlMs)));
|
|
27
57
|
}
|
|
28
58
|
async delete(key) {
|
|
59
|
+
const reverseKey = TAGS_OF_PREFIX + key;
|
|
60
|
+
const tags = await this.redis.smembers(reverseKey);
|
|
61
|
+
for (const tag of tags)
|
|
62
|
+
await this.redis.zrem(TAG_PREFIX + tag, key);
|
|
63
|
+
if (tags.length > 0)
|
|
64
|
+
await this.redis.del(reverseKey);
|
|
29
65
|
return (await this.redis.del(key)) > 0;
|
|
30
66
|
}
|
|
31
67
|
async flushPrefix(prefix) {
|
|
68
|
+
const pattern = `${escapeGlob(prefix)}*`;
|
|
32
69
|
let cursor = '0';
|
|
33
70
|
do {
|
|
34
|
-
const [next, keys] = await this.redis.scan(cursor, 'MATCH',
|
|
71
|
+
const [next, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
|
|
35
72
|
cursor = next;
|
|
36
73
|
if (keys.length > 0)
|
|
37
74
|
await this.redis.del(...keys);
|
|
@@ -40,9 +77,11 @@ export class RedisCacheDriver {
|
|
|
40
77
|
async flushTags(tags) {
|
|
41
78
|
for (const tag of tags) {
|
|
42
79
|
const tagKey = TAG_PREFIX + tag;
|
|
43
|
-
const keys = await this.redis.
|
|
44
|
-
if (keys.length > 0)
|
|
80
|
+
const keys = await this.redis.zrange(tagKey, '0', '-1');
|
|
81
|
+
if (keys.length > 0) {
|
|
45
82
|
await this.redis.del(...keys);
|
|
83
|
+
await this.redis.del(...keys.map((key) => TAGS_OF_PREFIX + key));
|
|
84
|
+
}
|
|
46
85
|
await this.redis.del(tagKey);
|
|
47
86
|
}
|
|
48
87
|
}
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,28 @@ export class MissingCacheScopeError extends BasaltError {
|
|
|
11
11
|
function isEnvelope(value) {
|
|
12
12
|
return typeof value === 'object' && value !== null && value.__swr === 1;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Marker stored in place of a literal `undefined`. Drivers report a miss as
|
|
16
|
+
* `undefined`, so an undefined VALUE would be indistinguishable from "not cached"
|
|
17
|
+
* — the factory would rerun on every call (and on Redis, `JSON.stringify(undefined)`
|
|
18
|
+
* writes the invalid literal `undefined`). Storing the marker makes the hit real.
|
|
19
|
+
*/
|
|
20
|
+
const UNDEFINED_MARKER = { __undefined: 1 };
|
|
21
|
+
function isUndefinedMarker(value) {
|
|
22
|
+
return (typeof value === 'object' && value !== null && value.__undefined === 1);
|
|
23
|
+
}
|
|
24
|
+
/** Value as written to the driver: `undefined` becomes the marker. */
|
|
25
|
+
function wrap(value) {
|
|
26
|
+
return value === undefined ? UNDEFINED_MARKER : value;
|
|
27
|
+
}
|
|
28
|
+
/** Value as read back from the driver: markers and SWR envelopes are unwrapped. */
|
|
29
|
+
function unwrap(stored) {
|
|
30
|
+
if (isEnvelope(stored))
|
|
31
|
+
return stored.v;
|
|
32
|
+
if (isUndefinedMarker(stored))
|
|
33
|
+
return undefined;
|
|
34
|
+
return stored;
|
|
35
|
+
}
|
|
14
36
|
function isSwr(value) {
|
|
15
37
|
return typeof value === 'object' && value !== null && 'staleFor' in value;
|
|
16
38
|
}
|
|
@@ -35,11 +57,11 @@ export class Cache {
|
|
|
35
57
|
}
|
|
36
58
|
async get(key, fallback) {
|
|
37
59
|
const stored = await this.driver.get(this.key(key));
|
|
38
|
-
const value =
|
|
60
|
+
const value = unwrap(stored);
|
|
39
61
|
return value === undefined ? fallback : value;
|
|
40
62
|
}
|
|
41
63
|
async put(key, value, ttl) {
|
|
42
|
-
await this.driver.set(this.key(key), value, ttl === undefined ? undefined : parseDuration(ttl));
|
|
64
|
+
await this.driver.set(this.key(key), wrap(value), ttl === undefined ? undefined : parseDuration(ttl));
|
|
43
65
|
}
|
|
44
66
|
async remember(key, ttlOrOptions, factory) {
|
|
45
67
|
return this.rememberWithTags(key, ttlOrOptions, factory, []);
|
|
@@ -60,7 +82,7 @@ export class Cache {
|
|
|
60
82
|
const scopedTags = tags.map((tag) => `${this.root()}${tag}`);
|
|
61
83
|
return {
|
|
62
84
|
put: async (key, value, ttl) => {
|
|
63
|
-
await this.driver.set(this.key(key), value, ttl === undefined ? undefined : parseDuration(ttl), scopedTags);
|
|
85
|
+
await this.driver.set(this.key(key), wrap(value), ttl === undefined ? undefined : parseDuration(ttl), scopedTags);
|
|
64
86
|
},
|
|
65
87
|
remember: (key, ttlOrOptions, factory) => this.rememberWithTags(key, ttlOrOptions, factory, scopedTags),
|
|
66
88
|
flush: async () => {
|
|
@@ -73,10 +95,12 @@ export class Cache {
|
|
|
73
95
|
const stored = await this.driver.get(fullKey);
|
|
74
96
|
// Plain hard-TTL remember (no staleFor): unchanged cache-aside with raw values.
|
|
75
97
|
if (!isSwr(ttlOrOptions)) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
98
|
+
// A driver miss is `undefined`; anything else is a hit, INCLUDING a stored
|
|
99
|
+
// undefined-marker — so a factory that legitimately returns undefined is
|
|
100
|
+
// cached once instead of rerunning on every call.
|
|
101
|
+
if (stored !== undefined)
|
|
102
|
+
return unwrap(stored);
|
|
103
|
+
return this.compute(fullKey, () => factory(), (value) => this.driver.set(fullKey, wrap(value), parseDuration(ttlOrOptions), tags));
|
|
80
104
|
}
|
|
81
105
|
// Stale-while-revalidate path.
|
|
82
106
|
const ttlMs = parseDuration(ttlOrOptions.ttl);
|
|
@@ -105,7 +129,7 @@ export class Cache {
|
|
|
105
129
|
}
|
|
106
130
|
else if (stored !== undefined) {
|
|
107
131
|
// A raw value written by put()/plain remember(): treat as fresh, no windows.
|
|
108
|
-
return stored;
|
|
132
|
+
return unwrap(stored);
|
|
109
133
|
}
|
|
110
134
|
return this.compute(fullKey, () => factory(), store);
|
|
111
135
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/cache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=22.5.0"
|
|
6
|
+
},
|
|
4
7
|
"description": "Basalt cache layer: Redis and Memory drivers, tags, TTL, stampede protection and automatic per-tenant isolation.",
|
|
5
8
|
"license": "MIT",
|
|
6
9
|
"type": "module",
|
|
10
|
+
"sideEffects": false,
|
|
7
11
|
"exports": {
|
|
8
12
|
".": {
|
|
9
13
|
"types": "./dist/index.d.ts",
|
|
@@ -15,7 +19,7 @@
|
|
|
15
19
|
],
|
|
16
20
|
"dependencies": {
|
|
17
21
|
"ioredis": "^6.0.0",
|
|
18
|
-
"@basaltkit/core": "^1.3.
|
|
22
|
+
"@basaltkit/core": "^1.3.1"
|
|
19
23
|
},
|
|
20
24
|
"devDependencies": {
|
|
21
25
|
"@types/node": "^26.3.0",
|