@basaltkit/cache 1.3.0 → 1.4.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 CHANGED
@@ -207,28 +207,118 @@ const cacheB = new Cache(new RedisCacheDriver(redis))
207
207
 
208
208
  #### `CacheOptions`
209
209
 
210
- | Option | Type | Required? | Default | Description |
211
- |---|---|---|---|---|
212
- | `prefix` | `string` | No | `'basalt'` | Root prefix for all keys. |
213
- | `scope` | `(() => string \| undefined) \| null` | No | reads `ctx().tenant.id` → `tenant:<id>` | Dynamic prefix segment, resolved on each operation. `null` disables tenant isolation. |
214
- | `onMissingScope` | `'global' \| 'error'` | No | `'error'` when `tenancyPlugin` is registered, else `'global'` | What an operation does when the scope resolves to `undefined` (e.g. a background job outside request context). In multi-tenant apps the default fails CLOSED (`MissingCacheScopeError`) instead of silently sharing one namespace across tenants; single-tenant apps keep the global namespace. Explicit values always win. |
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
+ `flush()` follows the same gate. It wipes a whole prefix, so in a multi-tenant app an unresolved
248
+ scope must fail closed — otherwise one mis-scoped call clears every tenant's cache. Without
249
+ tenancy, the prefix *is* this app's own cache and clearing it is exactly what `flush()` means, so
250
+ it proceeds. An explicit `onMissingScope: 'error'` keeps `flush()` fail-closed either way, and
251
+ `scope: null` (a deliberate global cache) may always flush its namespace.
252
+
253
+ ```ts
254
+ // Multi-tenant: this now throws instead of poisoning the shared namespace.
255
+ cachePlugin({ driver: 'redis', url }) // + tenancyPlugin() registered → onMissingScope: 'error'
256
+
257
+ // Deliberate global cache — opt out explicitly, and the scope check never runs.
258
+ cachePlugin({ driver: 'redis', url, scope: null })
259
+
260
+ // Keep the old permissive behaviour, knowingly.
261
+ cachePlugin({ driver: 'redis', url, onMissingScope: 'global' })
262
+ ```
263
+
264
+ **`flush()` always fails closed**, whatever `onMissingScope` says: if `scope` is not `null` and
265
+ resolves `undefined`, it throws rather than wiping the whole prefix. A mis-scoped `flush()` under
266
+ `'global'` would delete **every tenant's** cache in one call, and no convenience is worth that.
267
+ `scope: null` is exempt — you declared the cache global, so its "everything" is genuinely
268
+ everything you meant.
269
+
270
+ To flush one tenant, call it inside that tenant's context; to flush the global namespace of a
271
+ tenant-scoped cache, build a second `Cache` with `scope: null`.
215
272
 
216
273
  ### `cachePlugin(options?: CachePluginOptions)`
217
274
 
218
275
  Registers `Cache` in the container under the `CACHE` token and disconnects the driver on application `shutdown`.
219
276
 
277
+ #### Drivers
278
+
279
+ **`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).
280
+
281
+ | Option | Type | Default | Purpose |
282
+ |---|---|---|---|
283
+ | `maxEntries` | `number` | `10_000` | Live-entry cap before eviction; `Infinity` disables it. |
284
+
285
+ It also exposes `size` (live entry count) for diagnostics and tests.
286
+
287
+ **`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:
288
+
289
+ - **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.
290
+ - **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__:*`.
291
+
220
292
  #### `CachePluginOptions` (extends `CacheOptions`)
221
293
 
222
- | Option | Type | Required? | Default | Description |
223
- |---|---|---|---|---|
224
- | `driver` | `'memory' \| 'redis'` | No | `'memory'` | Which driver to use. |
225
- | `url` | `string` | Yes, with `driver: 'redis'` | — | Redis connection URL (e.g. `redis://localhost:6379`). |
226
- | `prefix`, `scope` | — | No | see `CacheOptions` | Inherited from `CacheOptions`. |
294
+ | Option | Type | Default | Purpose |
295
+ |---|---|---|---|
296
+ | `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. |
297
+ | `url` | `string` | | Required with `driver: 'redis'`. Redis connection URL (e.g. `redis://localhost:6379`). |
298
+ | `prefix`, `scope`, `onMissingScope`, `now` | — | see `CacheOptions` | Inherited from `CacheOptions`. |
299
+
300
+ The plugin registers the `Cache` as a container singleton and `disconnect()`s the driver on
301
+ application `shutdown`.
227
302
 
228
303
  ### `CACHE`
229
304
 
230
305
  Dependency injection token: `app.container.get(CACHE)` returns the `Cache` instance.
231
306
 
307
+ ### Errors
308
+
309
+ | Error | Code | When |
310
+ |---|---|---|
311
+ | `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`. |
312
+ | `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'`. |
313
+
314
+ There are no other error classes: driver-level faults (a Redis connection error) propagate from
315
+ `ioredis` unchanged.
316
+
317
+ ### Hooks & events
318
+
319
+ This package emits none. `onMissingScope` is a policy switch, not a callback, and there is no
320
+ hit/miss event bus — measure at the `remember` call site if you need cache metrics.
321
+
232
322
  ### `interface CacheDriver` (Advanced)
233
323
 
234
324
  Contract that any driver must implement — implement it to create your own storage:
@@ -272,6 +362,12 @@ This is by design: `remember`'s deduplication is **per process** (it uses an in-
272
362
  **`flush()` deleted less than I expected.**
273
363
  `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
364
 
365
+ **`CACHE_SCOPE_MISSING` — "Refusing cache operation: a tenant-scoped cache resolved no tenant".**
366
+ 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.
367
+
368
+ **`CACHE_SCOPE_MISSING` on `flush()` even though `onMissingScope` is `'global'`.**
369
+ Deliberate. `flush()` always fails closed, because a mis-scoped whole-namespace wipe would delete every tenant's cache. Only `scope: null` exempts it.
370
+
275
371
  **I configured `driver: 'redis'` and the application fails to boot/use the cache.**
276
372
  With `driver: 'redis'`, the `url` option is required. Also verify that the Redis server is reachable at that URL.
277
373
 
@@ -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
  }
@@ -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 (entry.expiresAt !== undefined && Date.now() >= entry.expiresAt) {
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
  }
@@ -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);
@@ -1,6 +1,24 @@
1
1
  import { Redis } from 'ioredis';
2
- /** Namespace for tag sets in Redis, outside the value key space. */
3
- const TAG_PREFIX = '__tags__:';
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
- await this.redis.sadd(TAG_PREFIX + tag, key);
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', `${prefix}*`, 'COUNT', 200);
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.smembers(tagKey);
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.d.ts CHANGED
@@ -19,8 +19,9 @@ export interface CacheOptions {
19
19
  * What to do when the scope function resolves nothing (no tenant in context):
20
20
  * `'global'` (default) shares one namespace — convenient but a per-tenant value
21
21
  * cached without a tenant leaks to others; `'error'` fails closed (throws
22
- * {@link MissingCacheScopeError}) on read/write. `flush()` ALWAYS fails closed
23
- * regardless, so a mis-scoped call can't wipe every tenant's cache.
22
+ * {@link MissingCacheScopeError}) on read/write. In a multi-tenant app (or
23
+ * with an explicit `'error'`), `flush()` fails closed too, so a mis-scoped
24
+ * call can't wipe every tenant's cache.
24
25
  */
25
26
  onMissingScope?: 'global' | 'error';
26
27
  /** Injectable clock (ms) for stale-while-revalidate windows. Default: Date.now. */
@@ -39,13 +40,25 @@ export interface SwrOptions {
39
40
  }
40
41
  export declare class Cache {
41
42
  private readonly driver;
43
+ /**
44
+ * Whether the host app registered `@basaltkit/tenancy`. `cachePlugin` wires
45
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
46
+ * not an import. Defaults to `false` (single-tenant).
47
+ */
48
+ private readonly tenancyActive;
42
49
  private readonly prefix;
43
50
  private readonly scope;
44
51
  private readonly onMissingScope;
45
52
  private readonly now;
46
53
  /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
47
54
  private readonly pending;
48
- constructor(driver: CacheDriver, options?: CacheOptions);
55
+ constructor(driver: CacheDriver, options?: CacheOptions,
56
+ /**
57
+ * Whether the host app registered `@basaltkit/tenancy`. `cachePlugin` wires
58
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
59
+ * not an import. Defaults to `false` (single-tenant).
60
+ */
61
+ tenancyActive?: () => boolean);
49
62
  get<T>(key: string): Promise<T | undefined>;
50
63
  get<T>(key: string, fallback: T): Promise<T>;
51
64
  put(key: string, value: unknown, ttl?: DurationInput): Promise<void>;
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
  }
@@ -20,14 +42,22 @@ const defaultScope = () => {
20
42
  };
21
43
  export class Cache {
22
44
  driver;
45
+ tenancyActive;
23
46
  prefix;
24
47
  scope;
25
48
  onMissingScope;
26
49
  now;
27
50
  /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
28
51
  pending = new Map();
29
- constructor(driver, options = {}) {
52
+ constructor(driver, options = {},
53
+ /**
54
+ * Whether the host app registered `@basaltkit/tenancy`. `cachePlugin` wires
55
+ * this to the container's `'tenancy:active'` metadata marker — a signal,
56
+ * not an import. Defaults to `false` (single-tenant).
57
+ */
58
+ tenancyActive = () => false) {
30
59
  this.driver = driver;
60
+ this.tenancyActive = tenancyActive;
31
61
  this.prefix = options.prefix ?? 'basalt';
32
62
  this.scope = options.scope === undefined ? defaultScope : options.scope;
33
63
  this.onMissingScope = options.onMissingScope ?? 'global';
@@ -35,11 +65,11 @@ export class Cache {
35
65
  }
36
66
  async get(key, fallback) {
37
67
  const stored = await this.driver.get(this.key(key));
38
- const value = isEnvelope(stored) ? stored.v : stored;
68
+ const value = unwrap(stored);
39
69
  return value === undefined ? fallback : value;
40
70
  }
41
71
  async put(key, value, ttl) {
42
- await this.driver.set(this.key(key), value, ttl === undefined ? undefined : parseDuration(ttl));
72
+ await this.driver.set(this.key(key), wrap(value), ttl === undefined ? undefined : parseDuration(ttl));
43
73
  }
44
74
  async remember(key, ttlOrOptions, factory) {
45
75
  return this.rememberWithTags(key, ttlOrOptions, factory, []);
@@ -49,10 +79,15 @@ export class Cache {
49
79
  }
50
80
  /** Clears only the keys under this prefix/scope — never the entire Redis. */
51
81
  async flush() {
52
- // Always fail closed: a whole-namespace wipe with an unresolved tenant scope
53
- // would delete EVERY tenant's cache. `scope:null` (deliberate global) is fine.
54
- if (this.scope !== null && this.scope() === undefined)
82
+ // Fail closed where a wipe could cross a boundary: with tenancy registered
83
+ // (or an explicit onMissingScope:'error'), an unresolved scope would delete
84
+ // EVERY tenant's cache. In a single-tenant app the whole prefix IS this
85
+ // app's cache, which is exactly what flush() means — so it proceeds.
86
+ // `scope:null` (deliberate global) is fine either way.
87
+ const failClosed = this.tenancyActive() || this.onMissingScope === 'error';
88
+ if (failClosed && this.scope !== null && this.scope() === undefined) {
55
89
  throw new MissingCacheScopeError('flush');
90
+ }
56
91
  await this.driver.flushPrefix(this.root());
57
92
  }
58
93
  /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
@@ -60,7 +95,7 @@ export class Cache {
60
95
  const scopedTags = tags.map((tag) => `${this.root()}${tag}`);
61
96
  return {
62
97
  put: async (key, value, ttl) => {
63
- await this.driver.set(this.key(key), value, ttl === undefined ? undefined : parseDuration(ttl), scopedTags);
98
+ await this.driver.set(this.key(key), wrap(value), ttl === undefined ? undefined : parseDuration(ttl), scopedTags);
64
99
  },
65
100
  remember: (key, ttlOrOptions, factory) => this.rememberWithTags(key, ttlOrOptions, factory, scopedTags),
66
101
  flush: async () => {
@@ -73,10 +108,12 @@ export class Cache {
73
108
  const stored = await this.driver.get(fullKey);
74
109
  // Plain hard-TTL remember (no staleFor): unchanged cache-aside with raw values.
75
110
  if (!isSwr(ttlOrOptions)) {
76
- const cached = isEnvelope(stored) ? stored.v : stored;
77
- if (cached !== undefined)
78
- return cached;
79
- return this.compute(fullKey, () => factory(), (value) => this.driver.set(fullKey, value, parseDuration(ttlOrOptions), tags));
111
+ // A driver miss is `undefined`; anything else is a hit, INCLUDING a stored
112
+ // undefined-marker so a factory that legitimately returns undefined is
113
+ // cached once instead of rerunning on every call.
114
+ if (stored !== undefined)
115
+ return unwrap(stored);
116
+ return this.compute(fullKey, () => factory(), (value) => this.driver.set(fullKey, wrap(value), parseDuration(ttlOrOptions), tags));
80
117
  }
81
118
  // Stale-while-revalidate path.
82
119
  const ttlMs = parseDuration(ttlOrOptions.ttl);
@@ -105,7 +142,7 @@ export class Cache {
105
142
  }
106
143
  else if (stored !== undefined) {
107
144
  // A raw value written by put()/plain remember(): treat as fresh, no windows.
108
- return stored;
145
+ return unwrap(stored);
109
146
  }
110
147
  return this.compute(fullKey, () => factory(), store);
111
148
  }
@@ -174,11 +211,11 @@ export function cachePlugin(options = {}) {
174
211
  // with no resolvable tenant scope throws instead of silently sharing
175
212
  // one global namespace across tenants. Single-tenant apps (no tenancy)
176
213
  // are untouched, and an explicit `onMissingScope`/custom `scope` wins.
177
- const tenancyActive = ensureMetadata(container).get('tenancy:active').length > 0;
178
- const resolved = options.onMissingScope === undefined && options.scope === undefined && tenancyActive
214
+ const tenancyActive = () => ensureMetadata(container).get('tenancy:active').length > 0;
215
+ const resolved = options.onMissingScope === undefined && options.scope === undefined && tenancyActive()
179
216
  ? { ...options, onMissingScope: 'error' }
180
217
  : options;
181
- return new Cache(driver, resolved);
218
+ return new Cache(driver, resolved, tenancyActive);
182
219
  });
183
220
  },
184
221
  async shutdown() {
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@basaltkit/cache",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
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.0"
22
+ "@basaltkit/core": "^1.3.1"
19
23
  },
20
24
  "devDependencies": {
21
25
  "@types/node": "^26.3.0",