@basaltkit/cache 1.1.0 → 1.2.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
@@ -1,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://basaltkit-docs.pages.dev">
3
+ <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
4
+ </a>
5
+ </p>
6
+
1
7
  # @basaltkit/cache
2
8
 
3
9
  Basalt's cache layer: stores the results of slow operations (database queries, external API calls, heavy computations) so they can be returned instantly next time. You need this module when your application repeats the same work over and over and you want to make it faster and cheaper.
@@ -95,6 +101,27 @@ const plans = await cache.remember('plans', '1h', async () => {
95
101
  })
96
102
  ```
97
103
 
104
+ ### Stale-while-revalidate (serve fast, refresh in the background)
105
+
106
+ For values that are expensive to build but tolerate being *slightly* out of date
107
+ (dashboards, feeds, pricing pages), pass `{ ttl, staleFor }` instead of a plain
108
+ TTL. The value is **fresh** for `ttl`; for a further `staleFor` window a read gets
109
+ the **stale** value **instantly** while a single background revalidation refreshes
110
+ it. Only after `ttl + staleFor` does a read block on the factory again:
111
+
112
+ ```ts
113
+ const feed = await cache.remember('feed', { ttl: '1m', staleFor: '10m' }, () => buildFeed())
114
+ // 0–1m → fresh, served from cache
115
+ // 1–11m → stale value returned immediately; ONE background refresh runs
116
+ // > 11m → hard-expired; the next read blocks and recomputes
117
+ ```
118
+
119
+ No caller ever waits for a refresh during the stale window, and concurrent stale
120
+ reads trigger only **one** background revalidation (same stampede protection as
121
+ `remember`). If a background refresh throws, the stale value keeps being served
122
+ until it hard-expires — a failing upstream never turns into an error for the user.
123
+ Works with `tags(...)` too: `cache.tags('feed').remember(key, { ttl, staleFor }, fn)`.
124
+
98
125
  ### Deleting entries (`forget` / `flush`)
99
126
 
100
127
  ```ts
@@ -173,7 +200,7 @@ const cacheB = new Cache(new RedisCacheDriver(redis))
173
200
  |---|---|---|
174
201
  | `get` | `get<T>(key: string): Promise<T \| undefined>` / `get<T>(key: string, fallback: T): Promise<T>` | Reads a value; returns `undefined` (or the `fallback`) on miss/expiration. |
175
202
  | `put` | `put(key: string, value: unknown, ttl?: DurationInput): Promise<void>` | Stores a value, with an optional TTL. |
176
- | `remember` | `remember<T>(key: string, ttl: DurationInput, factory: () => Promise<T> \| T): Promise<T>` | Returns the cached value or runs the `factory` (only once, even with concurrent calls) and stores the result. |
203
+ | `remember` | `remember<T>(key, ttl: DurationInput, factory): Promise<T>` or `remember<T>(key, { ttl, staleFor }: SwrOptions, factory)` | Cache-aside with stampede protection. With `{ ttl, staleFor }` it becomes stale-while-revalidate: serves a stale value while refreshing once in the background. |
177
204
  | `forget` | `forget(key: string): Promise<boolean>` | Deletes a key; `true` if it existed. |
178
205
  | `flush` | `flush(): Promise<void>` | Deletes all keys in the current prefix/scope. |
179
206
  | `tags` | `tags(...tags: string[])` | Returns an object with `put`, `remember` and `flush` scoped to the given tags. |
@@ -0,0 +1,12 @@
1
+ /** Cache driver contract. Every driver passes the same conformance suite. */
2
+ export interface CacheDriver {
3
+ /** Returns the value, or undefined on miss/expired. */
4
+ get(key: string): Promise<unknown>;
5
+ set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void>;
6
+ delete(key: string): Promise<boolean>;
7
+ /** Removes all keys starting with the prefix. */
8
+ flushPrefix(prefix: string): Promise<void>;
9
+ /** Removes all keys associated with any of the tags. */
10
+ flushTags(tags: string[]): Promise<void>;
11
+ disconnect(): Promise<void>;
12
+ }
package/dist/driver.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { CacheDriver } from '../driver.js';
2
+ export declare class MemoryCacheDriver implements CacheDriver {
3
+ private readonly store;
4
+ get(key: string): Promise<unknown>;
5
+ set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void>;
6
+ delete(key: string): Promise<boolean>;
7
+ flushPrefix(prefix: string): Promise<void>;
8
+ flushTags(tags: string[]): Promise<void>;
9
+ disconnect(): Promise<void>;
10
+ }
@@ -0,0 +1,38 @@
1
+ export class MemoryCacheDriver {
2
+ store = new Map();
3
+ async get(key) {
4
+ const entry = this.store.get(key);
5
+ if (!entry)
6
+ return undefined;
7
+ if (entry.expiresAt !== undefined && Date.now() >= entry.expiresAt) {
8
+ this.store.delete(key);
9
+ return undefined;
10
+ }
11
+ return entry.value;
12
+ }
13
+ async set(key, value, ttlMs, tags = []) {
14
+ this.store.set(key, {
15
+ value,
16
+ ...(ttlMs !== undefined ? { expiresAt: Date.now() + ttlMs } : {}),
17
+ tags: new Set(tags),
18
+ });
19
+ }
20
+ async delete(key) {
21
+ return this.store.delete(key);
22
+ }
23
+ async flushPrefix(prefix) {
24
+ for (const key of this.store.keys()) {
25
+ if (key.startsWith(prefix))
26
+ this.store.delete(key);
27
+ }
28
+ }
29
+ async flushTags(tags) {
30
+ for (const [key, entry] of this.store) {
31
+ if (tags.some((tag) => entry.tags.has(tag)))
32
+ this.store.delete(key);
33
+ }
34
+ }
35
+ async disconnect() {
36
+ this.store.clear();
37
+ }
38
+ }
@@ -0,0 +1,13 @@
1
+ import { Redis } from 'ioredis';
2
+ import type { CacheDriver } from '../driver.js';
3
+ export declare class RedisCacheDriver implements CacheDriver {
4
+ private readonly redis;
5
+ constructor(redis: Redis);
6
+ static fromUrl(url: string): RedisCacheDriver;
7
+ get(key: string): Promise<unknown>;
8
+ set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void>;
9
+ delete(key: string): Promise<boolean>;
10
+ flushPrefix(prefix: string): Promise<void>;
11
+ flushTags(tags: string[]): Promise<void>;
12
+ disconnect(): Promise<void>;
13
+ }
@@ -0,0 +1,52 @@
1
+ import { Redis } from 'ioredis';
2
+ /** Namespace for tag sets in Redis, outside the value key space. */
3
+ const TAG_PREFIX = '__tags__:';
4
+ export class RedisCacheDriver {
5
+ redis;
6
+ constructor(redis) {
7
+ this.redis = redis;
8
+ }
9
+ static fromUrl(url) {
10
+ return new RedisCacheDriver(new Redis(url));
11
+ }
12
+ async get(key) {
13
+ const raw = await this.redis.get(key);
14
+ return raw === null ? undefined : JSON.parse(raw);
15
+ }
16
+ async set(key, value, ttlMs, tags = []) {
17
+ const raw = JSON.stringify(value);
18
+ if (ttlMs !== undefined) {
19
+ await this.redis.set(key, raw, 'PX', Math.max(1, Math.ceil(ttlMs)));
20
+ }
21
+ else {
22
+ await this.redis.set(key, raw);
23
+ }
24
+ for (const tag of tags) {
25
+ await this.redis.sadd(TAG_PREFIX + tag, key);
26
+ }
27
+ }
28
+ async delete(key) {
29
+ return (await this.redis.del(key)) > 0;
30
+ }
31
+ async flushPrefix(prefix) {
32
+ let cursor = '0';
33
+ do {
34
+ const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 200);
35
+ cursor = next;
36
+ if (keys.length > 0)
37
+ await this.redis.del(...keys);
38
+ } while (cursor !== '0');
39
+ }
40
+ async flushTags(tags) {
41
+ for (const tag of tags) {
42
+ const tagKey = TAG_PREFIX + tag;
43
+ const keys = await this.redis.smembers(tagKey);
44
+ if (keys.length > 0)
45
+ await this.redis.del(...keys);
46
+ await this.redis.del(tagKey);
47
+ }
48
+ }
49
+ async disconnect() {
50
+ await this.redis.quit();
51
+ }
52
+ }
package/dist/index.d.ts CHANGED
@@ -1,46 +1,12 @@
1
- import * as _basaltkit_core from '@basaltkit/core';
2
- import { DurationInput, BasaltError } from '@basaltkit/core';
3
- import { Redis } from 'ioredis';
4
-
5
- /** Cache driver contract. Every driver passes the same conformance suite. */
6
- interface CacheDriver {
7
- /** Returns the value, or undefined on miss/expired. */
8
- get(key: string): Promise<unknown>;
9
- set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void>;
10
- delete(key: string): Promise<boolean>;
11
- /** Removes all keys starting with the prefix. */
12
- flushPrefix(prefix: string): Promise<void>;
13
- /** Removes all keys associated with any of the tags. */
14
- flushTags(tags: string[]): Promise<void>;
15
- disconnect(): Promise<void>;
16
- }
17
-
18
- declare class MemoryCacheDriver implements CacheDriver {
19
- private readonly store;
20
- get(key: string): Promise<unknown>;
21
- set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void>;
22
- delete(key: string): Promise<boolean>;
23
- flushPrefix(prefix: string): Promise<void>;
24
- flushTags(tags: string[]): Promise<void>;
25
- disconnect(): Promise<void>;
26
- }
27
-
28
- declare class RedisCacheDriver implements CacheDriver {
29
- private readonly redis;
30
- constructor(redis: Redis);
31
- static fromUrl(url: string): RedisCacheDriver;
32
- get(key: string): Promise<unknown>;
33
- set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void>;
34
- delete(key: string): Promise<boolean>;
35
- flushPrefix(prefix: string): Promise<void>;
36
- flushTags(tags: string[]): Promise<void>;
37
- disconnect(): Promise<void>;
38
- }
39
-
40
- declare class MissingCacheScopeError extends BasaltError {
1
+ import { BasaltError, type DurationInput } from '@basaltkit/core';
2
+ import type { CacheDriver } from './driver.js';
3
+ export type { CacheDriver } from './driver.js';
4
+ export { MemoryCacheDriver } from './drivers/memory.js';
5
+ export { RedisCacheDriver } from './drivers/redis.js';
6
+ export declare class MissingCacheScopeError extends BasaltError {
41
7
  constructor(op: string);
42
8
  }
43
- interface CacheOptions {
9
+ export interface CacheOptions {
44
10
  /** Root prefix for all keys. Default: 'basalt' */
45
11
  prefix?: string;
46
12
  /**
@@ -57,13 +23,27 @@ interface CacheOptions {
57
23
  * regardless, so a mis-scoped call can't wipe every tenant's cache.
58
24
  */
59
25
  onMissingScope?: 'global' | 'error';
26
+ /** Injectable clock (ms) for stale-while-revalidate windows. Default: Date.now. */
27
+ now?: () => number;
28
+ }
29
+ /** SwrOptions turns `remember` into a stale-while-revalidate read. */
30
+ export interface SwrOptions {
31
+ /** How long the value stays fresh (served without revalidation). */
32
+ ttl: DurationInput;
33
+ /**
34
+ * Extra window after `ttl` during which a stale value is served immediately
35
+ * while a single background revalidation refreshes it. After `ttl + staleFor`
36
+ * the entry is hard-expired and the next read blocks on the factory.
37
+ */
38
+ staleFor: DurationInput;
60
39
  }
61
- declare class Cache {
40
+ export declare class Cache {
62
41
  private readonly driver;
63
42
  private readonly prefix;
64
43
  private readonly scope;
65
44
  private readonly onMissingScope;
66
- /** dedupe of in-flight factories — per-process stampede protection */
45
+ private readonly now;
46
+ /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
67
47
  private readonly pending;
68
48
  constructor(driver: CacheDriver, options?: CacheOptions);
69
49
  get<T>(key: string): Promise<T | undefined>;
@@ -74,26 +54,29 @@ declare class Cache {
74
54
  * for the same key share ONE execution of the factory.
75
55
  */
76
56
  remember<T>(key: string, ttl: DurationInput, factory: () => Promise<T> | T): Promise<T>;
57
+ remember<T>(key: string, options: SwrOptions, factory: () => Promise<T> | T): Promise<T>;
77
58
  forget(key: string): Promise<boolean>;
78
59
  /** Clears only the keys under this prefix/scope — never the entire Redis. */
79
60
  flush(): Promise<void>;
80
61
  /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
81
62
  tags(...tags: string[]): {
82
63
  put: (key: string, value: unknown, ttl?: DurationInput) => Promise<void>;
83
- remember: <T>(key: string, ttl: DurationInput, factory: () => Promise<T> | T) => Promise<T>;
64
+ remember: <T>(key: string, ttlOrOptions: DurationInput | SwrOptions, factory: () => Promise<T> | T) => Promise<T>;
84
65
  flush: () => Promise<void>;
85
66
  };
86
67
  private rememberWithTags;
68
+ /** Blocking cache-aside compute with per-key stampede dedupe. */
69
+ private compute;
70
+ /** Fire-and-forget SWR refresh: one per key, failures keep serving stale. */
71
+ private revalidate;
87
72
  private root;
88
73
  private key;
89
74
  }
90
- declare const CACHE: _basaltkit_core.Token<Cache>;
91
- interface CachePluginOptions extends CacheOptions {
75
+ export declare const CACHE: import("@basaltkit/core").Token<Cache>;
76
+ export interface CachePluginOptions extends CacheOptions {
92
77
  /** 'memory' (default), 'redis' (needs `url`), or a custom `CacheDriver` instance. */
93
78
  driver?: 'memory' | 'redis' | CacheDriver;
94
79
  /** Required with the 'redis' driver. */
95
80
  url?: string;
96
81
  }
97
- declare function cachePlugin(options?: CachePluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
98
-
99
- export { CACHE, Cache, type CacheDriver, type CacheOptions, type CachePluginOptions, MemoryCacheDriver, MissingCacheScopeError, RedisCacheDriver, cachePlugin };
82
+ export declare function cachePlugin(options?: CachePluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
package/dist/index.js CHANGED
@@ -1,218 +1,179 @@
1
- // src/index.ts
2
- import {
3
- BasaltError,
4
- createToken,
5
- definePlugin,
6
- parseDuration,
7
- tryCtx
8
- } from "@basaltkit/core";
9
-
10
- // src/drivers/memory.ts
11
- var MemoryCacheDriver = class {
12
- store = /* @__PURE__ */ new Map();
13
- async get(key) {
14
- const entry = this.store.get(key);
15
- if (!entry) return void 0;
16
- if (entry.expiresAt !== void 0 && Date.now() >= entry.expiresAt) {
17
- this.store.delete(key);
18
- return void 0;
1
+ import { BasaltError, createToken, definePlugin, parseDuration, tryCtx, } from '@basaltkit/core';
2
+ import { MemoryCacheDriver } from './drivers/memory.js';
3
+ import { RedisCacheDriver } from './drivers/redis.js';
4
+ export { MemoryCacheDriver } from './drivers/memory.js';
5
+ export { RedisCacheDriver } from './drivers/redis.js';
6
+ export class MissingCacheScopeError extends BasaltError {
7
+ constructor(op) {
8
+ super('CACHE_SCOPE_MISSING', `Refusing cache ${op}: a tenant-scoped cache resolved no tenant (ran without a tenant context). Establish a tenant, or use scope:null for a deliberate global cache.`);
19
9
  }
20
- return entry.value;
21
- }
22
- async set(key, value, ttlMs, tags = []) {
23
- this.store.set(key, {
24
- value,
25
- ...ttlMs !== void 0 ? { expiresAt: Date.now() + ttlMs } : {},
26
- tags: new Set(tags)
27
- });
28
- }
29
- async delete(key) {
30
- return this.store.delete(key);
31
- }
32
- async flushPrefix(prefix) {
33
- for (const key of this.store.keys()) {
34
- if (key.startsWith(prefix)) this.store.delete(key);
10
+ }
11
+ function isEnvelope(value) {
12
+ return typeof value === 'object' && value !== null && value.__swr === 1;
13
+ }
14
+ function isSwr(value) {
15
+ return typeof value === 'object' && value !== null && 'staleFor' in value;
16
+ }
17
+ const defaultScope = () => {
18
+ const tenant = tryCtx()?.['tenant'];
19
+ return tenant?.id ? `tenant:${tenant.id}` : undefined;
20
+ };
21
+ export class Cache {
22
+ driver;
23
+ prefix;
24
+ scope;
25
+ onMissingScope;
26
+ now;
27
+ /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
28
+ pending = new Map();
29
+ constructor(driver, options = {}) {
30
+ this.driver = driver;
31
+ this.prefix = options.prefix ?? 'basalt';
32
+ this.scope = options.scope === undefined ? defaultScope : options.scope;
33
+ this.onMissingScope = options.onMissingScope ?? 'global';
34
+ this.now = options.now ?? Date.now;
35
35
  }
36
- }
37
- async flushTags(tags) {
38
- for (const [key, entry] of this.store) {
39
- if (tags.some((tag) => entry.tags.has(tag))) this.store.delete(key);
36
+ async get(key, fallback) {
37
+ const stored = await this.driver.get(this.key(key));
38
+ const value = isEnvelope(stored) ? stored.v : stored;
39
+ return value === undefined ? fallback : value;
40
40
  }
41
- }
42
- async disconnect() {
43
- this.store.clear();
44
- }
45
- };
46
-
47
- // src/drivers/redis.ts
48
- import { Redis } from "ioredis";
49
- var TAG_PREFIX = "__tags__:";
50
- var RedisCacheDriver = class _RedisCacheDriver {
51
- constructor(redis) {
52
- this.redis = redis;
53
- }
54
- redis;
55
- static fromUrl(url) {
56
- return new _RedisCacheDriver(new Redis(url));
57
- }
58
- async get(key) {
59
- const raw = await this.redis.get(key);
60
- return raw === null ? void 0 : JSON.parse(raw);
61
- }
62
- async set(key, value, ttlMs, tags = []) {
63
- const raw = JSON.stringify(value);
64
- if (ttlMs !== void 0) {
65
- await this.redis.set(key, raw, "PX", Math.max(1, Math.ceil(ttlMs)));
66
- } else {
67
- await this.redis.set(key, raw);
41
+ async put(key, value, ttl) {
42
+ await this.driver.set(this.key(key), value, ttl === undefined ? undefined : parseDuration(ttl));
68
43
  }
69
- for (const tag of tags) {
70
- await this.redis.sadd(TAG_PREFIX + tag, key);
44
+ async remember(key, ttlOrOptions, factory) {
45
+ return this.rememberWithTags(key, ttlOrOptions, factory, []);
71
46
  }
72
- }
73
- async delete(key) {
74
- return await this.redis.del(key) > 0;
75
- }
76
- async flushPrefix(prefix) {
77
- let cursor = "0";
78
- do {
79
- const [next, keys] = await this.redis.scan(cursor, "MATCH", `${prefix}*`, "COUNT", 200);
80
- cursor = next;
81
- if (keys.length > 0) await this.redis.del(...keys);
82
- } while (cursor !== "0");
83
- }
84
- async flushTags(tags) {
85
- for (const tag of tags) {
86
- const tagKey = TAG_PREFIX + tag;
87
- const keys = await this.redis.smembers(tagKey);
88
- if (keys.length > 0) await this.redis.del(...keys);
89
- await this.redis.del(tagKey);
47
+ async forget(key) {
48
+ return this.driver.delete(this.key(key));
90
49
  }
91
- }
92
- async disconnect() {
93
- await this.redis.quit();
94
- }
95
- };
96
-
97
- // src/index.ts
98
- var MissingCacheScopeError = class extends BasaltError {
99
- constructor(op) {
100
- super(
101
- "CACHE_SCOPE_MISSING",
102
- `Refusing cache ${op}: a tenant-scoped cache resolved no tenant (ran without a tenant context). Establish a tenant, or use scope:null for a deliberate global cache.`
103
- );
104
- }
105
- };
106
- var defaultScope = () => {
107
- const tenant = tryCtx()?.["tenant"];
108
- return tenant?.id ? `tenant:${tenant.id}` : void 0;
109
- };
110
- var Cache = class {
111
- constructor(driver, options = {}) {
112
- this.driver = driver;
113
- this.prefix = options.prefix ?? "basalt";
114
- this.scope = options.scope === void 0 ? defaultScope : options.scope;
115
- this.onMissingScope = options.onMissingScope ?? "global";
116
- }
117
- driver;
118
- prefix;
119
- scope;
120
- onMissingScope;
121
- /** dedupe of in-flight factories — per-process stampede protection */
122
- pending = /* @__PURE__ */ new Map();
123
- async get(key, fallback) {
124
- const value = await this.driver.get(this.key(key));
125
- return value === void 0 ? fallback : value;
126
- }
127
- async put(key, value, ttl) {
128
- await this.driver.set(
129
- this.key(key),
130
- value,
131
- ttl === void 0 ? void 0 : parseDuration(ttl)
132
- );
133
- }
134
- /**
135
- * One-line cache-aside, with stampede protection: concurrent calls
136
- * for the same key share ONE execution of the factory.
137
- */
138
- async remember(key, ttl, factory) {
139
- return this.rememberWithTags(key, ttl, factory, []);
140
- }
141
- async forget(key) {
142
- return this.driver.delete(this.key(key));
143
- }
144
- /** Clears only the keys under this prefix/scope — never the entire Redis. */
145
- async flush() {
146
- if (this.scope !== null && this.scope() === void 0) throw new MissingCacheScopeError("flush");
147
- await this.driver.flushPrefix(this.root());
148
- }
149
- /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
150
- tags(...tags) {
151
- const scopedTags = tags.map((tag) => `${this.root()}${tag}`);
152
- return {
153
- put: async (key, value, ttl) => {
154
- await this.driver.set(
155
- this.key(key),
156
- value,
157
- ttl === void 0 ? void 0 : parseDuration(ttl),
158
- scopedTags
159
- );
160
- },
161
- remember: (key, ttl, factory) => this.rememberWithTags(key, ttl, factory, scopedTags),
162
- flush: async () => {
163
- await this.driver.flushTags(scopedTags);
164
- }
165
- };
166
- }
167
- async rememberWithTags(key, ttl, factory, tags) {
168
- const fullKey = this.key(key);
169
- const cached = await this.driver.get(fullKey);
170
- if (cached !== void 0) return cached;
171
- const inFlight = this.pending.get(fullKey);
172
- if (inFlight) return inFlight;
173
- const computation = (async () => {
174
- try {
175
- const value = await factory();
176
- await this.driver.set(fullKey, value, parseDuration(ttl), tags);
177
- return value;
178
- } finally {
179
- this.pending.delete(fullKey);
180
- }
181
- })();
182
- this.pending.set(fullKey, computation);
183
- return computation;
184
- }
185
- root() {
186
- if (this.scope === null) return `${this.prefix}:`;
187
- const scope = this.scope();
188
- if (scope === void 0 && this.onMissingScope === "error") throw new MissingCacheScopeError("operation");
189
- return scope ? `${this.prefix}:${scope}:` : `${this.prefix}:`;
190
- }
191
- key(key) {
192
- return this.root() + key;
193
- }
194
- };
195
- var CACHE = createToken("cache");
196
- function cachePlugin(options = {}) {
197
- let driver;
198
- return definePlugin({
199
- name: "basalt:cache",
200
- register({ container }) {
201
- container.singleton(CACHE, () => {
202
- driver = typeof options.driver === "object" ? options.driver : options.driver === "redis" ? RedisCacheDriver.fromUrl(options.url) : new MemoryCacheDriver();
203
- return new Cache(driver, options);
204
- });
205
- },
206
- async shutdown() {
207
- await driver?.disconnect();
50
+ /** Clears only the keys under this prefix/scope — never the entire Redis. */
51
+ 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)
55
+ throw new MissingCacheScopeError('flush');
56
+ await this.driver.flushPrefix(this.root());
57
+ }
58
+ /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
59
+ tags(...tags) {
60
+ const scopedTags = tags.map((tag) => `${this.root()}${tag}`);
61
+ return {
62
+ put: async (key, value, ttl) => {
63
+ await this.driver.set(this.key(key), value, ttl === undefined ? undefined : parseDuration(ttl), scopedTags);
64
+ },
65
+ remember: (key, ttlOrOptions, factory) => this.rememberWithTags(key, ttlOrOptions, factory, scopedTags),
66
+ flush: async () => {
67
+ await this.driver.flushTags(scopedTags);
68
+ },
69
+ };
70
+ }
71
+ async rememberWithTags(key, ttlOrOptions, factory, tags) {
72
+ const fullKey = this.key(key);
73
+ const stored = await this.driver.get(fullKey);
74
+ // Plain hard-TTL remember (no staleFor): unchanged cache-aside with raw values.
75
+ 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));
80
+ }
81
+ // Stale-while-revalidate path.
82
+ const ttlMs = parseDuration(ttlOrOptions.ttl);
83
+ const staleMs = parseDuration(ttlOrOptions.staleFor);
84
+ const store = (value) => {
85
+ const now = this.now();
86
+ const envelope = {
87
+ __swr: 1,
88
+ v: value,
89
+ freshUntil: now + ttlMs,
90
+ staleUntil: now + ttlMs + staleMs,
91
+ };
92
+ // Driver TTL is the hard window; Cache-layer windows gate fresh/stale/expired.
93
+ return this.driver.set(fullKey, envelope, ttlMs + staleMs, tags);
94
+ };
95
+ if (isEnvelope(stored)) {
96
+ const now = this.now();
97
+ if (now < stored.freshUntil)
98
+ return stored.v;
99
+ if (now < stored.staleUntil) {
100
+ // Serve stale immediately; refresh once in the background.
101
+ this.revalidate(fullKey, () => factory(), store);
102
+ return stored.v;
103
+ }
104
+ // Hard-expired → fall through to a blocking recompute.
105
+ }
106
+ else if (stored !== undefined) {
107
+ // A raw value written by put()/plain remember(): treat as fresh, no windows.
108
+ return stored;
109
+ }
110
+ return this.compute(fullKey, () => factory(), store);
111
+ }
112
+ /** Blocking cache-aside compute with per-key stampede dedupe. */
113
+ compute(fullKey, factory, store) {
114
+ const inFlight = this.pending.get(fullKey);
115
+ if (inFlight)
116
+ return inFlight;
117
+ const computation = (async () => {
118
+ try {
119
+ const value = await factory();
120
+ await store(value);
121
+ return value;
122
+ }
123
+ finally {
124
+ this.pending.delete(fullKey);
125
+ }
126
+ })();
127
+ this.pending.set(fullKey, computation);
128
+ return computation;
129
+ }
130
+ /** Fire-and-forget SWR refresh: one per key, failures keep serving stale. */
131
+ revalidate(fullKey, factory, store) {
132
+ if (this.pending.has(fullKey))
133
+ return;
134
+ const computation = (async () => {
135
+ try {
136
+ const value = await factory();
137
+ await store(value);
138
+ }
139
+ finally {
140
+ this.pending.delete(fullKey);
141
+ }
142
+ })();
143
+ this.pending.set(fullKey, computation);
144
+ // Never surface a background error as an unhandled rejection.
145
+ void computation.catch(() => undefined);
146
+ }
147
+ root() {
148
+ if (this.scope === null)
149
+ return `${this.prefix}:`; // deliberate global cache
150
+ const scope = this.scope();
151
+ if (scope === undefined && this.onMissingScope === 'error')
152
+ throw new MissingCacheScopeError('operation');
153
+ return scope ? `${this.prefix}:${scope}:` : `${this.prefix}:`;
154
+ }
155
+ key(key) {
156
+ return this.root() + key;
208
157
  }
209
- });
210
158
  }
211
- export {
212
- CACHE,
213
- Cache,
214
- MemoryCacheDriver,
215
- MissingCacheScopeError,
216
- RedisCacheDriver,
217
- cachePlugin
218
- };
159
+ export const CACHE = createToken('cache');
160
+ export function cachePlugin(options = {}) {
161
+ let driver;
162
+ return definePlugin({
163
+ name: 'basalt:cache',
164
+ register({ container }) {
165
+ container.singleton(CACHE, () => {
166
+ driver =
167
+ typeof options.driver === 'object'
168
+ ? options.driver
169
+ : options.driver === 'redis'
170
+ ? RedisCacheDriver.fromUrl(options.url)
171
+ : new MemoryCacheDriver();
172
+ return new Cache(driver, options);
173
+ });
174
+ },
175
+ async shutdown() {
176
+ await driver?.disconnect();
177
+ },
178
+ });
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/cache",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Basalt cache layer: Redis and Memory drivers, tags, TTL, stampede protection and automatic per-tenant isolation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,14 +14,13 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "ioredis": "^5.6.0",
18
- "@basaltkit/core": "^1.0.0"
17
+ "ioredis": "^6.0.0",
18
+ "@basaltkit/core": "^1.1.2"
19
19
  },
20
20
  "devDependencies": {
21
- "@types/node": "^22.15.0",
22
- "tsup": "^8.4.0",
23
- "typescript": "^5.8.0",
24
- "vitest": "^3.1.0",
21
+ "@types/node": "^26.3.0",
22
+ "typescript": "^7.0.2",
23
+ "vitest": "^4.1.11",
25
24
  "@basaltkit/tsconfig": "^0.24.0"
26
25
  },
27
26
  "publishConfig": {
@@ -29,11 +28,11 @@
29
28
  },
30
29
  "repository": {
31
30
  "type": "git",
32
- "url": "git+https://github.com/Zebedeu/basalt.git",
31
+ "url": "git+https://github.com/basaltkit/basalt.git",
33
32
  "directory": "packages/cache"
34
33
  },
35
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/cache#readme",
36
- "bugs": "https://github.com/Zebedeu/basalt/issues",
34
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/cache#readme",
35
+ "bugs": "https://github.com/basaltkit/basalt/issues",
37
36
  "keywords": [
38
37
  "basalt",
39
38
  "typescript",
@@ -41,7 +40,7 @@
41
40
  "redis"
42
41
  ],
43
42
  "scripts": {
44
- "build": "tsup src/index.ts --format esm --dts --clean",
43
+ "build": "tsc -p tsconfig.build.json",
45
44
  "test": "vitest run",
46
45
  "typecheck": "tsc --noEmit"
47
46
  }