@basaltkit/cache 1.1.0 → 1.2.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 CHANGED
@@ -95,6 +95,27 @@ const plans = await cache.remember('plans', '1h', async () => {
95
95
  })
96
96
  ```
97
97
 
98
+ ### Stale-while-revalidate (serve fast, refresh in the background)
99
+
100
+ For values that are expensive to build but tolerate being *slightly* out of date
101
+ (dashboards, feeds, pricing pages), pass `{ ttl, staleFor }` instead of a plain
102
+ TTL. The value is **fresh** for `ttl`; for a further `staleFor` window a read gets
103
+ the **stale** value **instantly** while a single background revalidation refreshes
104
+ it. Only after `ttl + staleFor` does a read block on the factory again:
105
+
106
+ ```ts
107
+ const feed = await cache.remember('feed', { ttl: '1m', staleFor: '10m' }, () => buildFeed())
108
+ // 0–1m → fresh, served from cache
109
+ // 1–11m → stale value returned immediately; ONE background refresh runs
110
+ // > 11m → hard-expired; the next read blocks and recomputes
111
+ ```
112
+
113
+ No caller ever waits for a refresh during the stale window, and concurrent stale
114
+ reads trigger only **one** background revalidation (same stampede protection as
115
+ `remember`). If a background refresh throws, the stale value keeps being served
116
+ until it hard-expires — a failing upstream never turns into an error for the user.
117
+ Works with `tags(...)` too: `cache.tags('feed').remember(key, { ttl, staleFor }, fn)`.
118
+
98
119
  ### Deleting entries (`forget` / `flush`)
99
120
 
100
121
  ```ts
@@ -173,7 +194,7 @@ const cacheB = new Cache(new RedisCacheDriver(redis))
173
194
  |---|---|---|
174
195
  | `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
196
  | `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. |
197
+ | `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
198
  | `forget` | `forget(key: string): Promise<boolean>` | Deletes a key; `true` if it existed. |
178
199
  | `flush` | `flush(): Promise<void>` | Deletes all keys in the current prefix/scope. |
179
200
  | `tags` | `tags(...tags: string[])` | Returns an object with `put`, `remember` and `flush` scoped to the given tags. |
package/dist/index.d.ts CHANGED
@@ -57,13 +57,27 @@ interface CacheOptions {
57
57
  * regardless, so a mis-scoped call can't wipe every tenant's cache.
58
58
  */
59
59
  onMissingScope?: 'global' | 'error';
60
+ /** Injectable clock (ms) for stale-while-revalidate windows. Default: Date.now. */
61
+ now?: () => number;
62
+ }
63
+ /** SwrOptions turns `remember` into a stale-while-revalidate read. */
64
+ interface SwrOptions {
65
+ /** How long the value stays fresh (served without revalidation). */
66
+ ttl: DurationInput;
67
+ /**
68
+ * Extra window after `ttl` during which a stale value is served immediately
69
+ * while a single background revalidation refreshes it. After `ttl + staleFor`
70
+ * the entry is hard-expired and the next read blocks on the factory.
71
+ */
72
+ staleFor: DurationInput;
60
73
  }
61
74
  declare class Cache {
62
75
  private readonly driver;
63
76
  private readonly prefix;
64
77
  private readonly scope;
65
78
  private readonly onMissingScope;
66
- /** dedupe of in-flight factories — per-process stampede protection */
79
+ private readonly now;
80
+ /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
67
81
  private readonly pending;
68
82
  constructor(driver: CacheDriver, options?: CacheOptions);
69
83
  get<T>(key: string): Promise<T | undefined>;
@@ -74,16 +88,21 @@ declare class Cache {
74
88
  * for the same key share ONE execution of the factory.
75
89
  */
76
90
  remember<T>(key: string, ttl: DurationInput, factory: () => Promise<T> | T): Promise<T>;
91
+ remember<T>(key: string, options: SwrOptions, factory: () => Promise<T> | T): Promise<T>;
77
92
  forget(key: string): Promise<boolean>;
78
93
  /** Clears only the keys under this prefix/scope — never the entire Redis. */
79
94
  flush(): Promise<void>;
80
95
  /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
81
96
  tags(...tags: string[]): {
82
97
  put: (key: string, value: unknown, ttl?: DurationInput) => Promise<void>;
83
- remember: <T>(key: string, ttl: DurationInput, factory: () => Promise<T> | T) => Promise<T>;
98
+ remember: <T>(key: string, ttlOrOptions: DurationInput | SwrOptions, factory: () => Promise<T> | T) => Promise<T>;
84
99
  flush: () => Promise<void>;
85
100
  };
86
101
  private rememberWithTags;
102
+ /** Blocking cache-aside compute with per-key stampede dedupe. */
103
+ private compute;
104
+ /** Fire-and-forget SWR refresh: one per key, failures keep serving stale. */
105
+ private revalidate;
87
106
  private root;
88
107
  private key;
89
108
  }
@@ -96,4 +115,4 @@ interface CachePluginOptions extends CacheOptions {
96
115
  }
97
116
  declare function cachePlugin(options?: CachePluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
98
117
 
99
- export { CACHE, Cache, type CacheDriver, type CacheOptions, type CachePluginOptions, MemoryCacheDriver, MissingCacheScopeError, RedisCacheDriver, cachePlugin };
118
+ export { CACHE, Cache, type CacheDriver, type CacheOptions, type CachePluginOptions, MemoryCacheDriver, MissingCacheScopeError, RedisCacheDriver, type SwrOptions, cachePlugin };
package/dist/index.js CHANGED
@@ -103,6 +103,12 @@ var MissingCacheScopeError = class extends BasaltError {
103
103
  );
104
104
  }
105
105
  };
106
+ function isEnvelope(value) {
107
+ return typeof value === "object" && value !== null && value.__swr === 1;
108
+ }
109
+ function isSwr(value) {
110
+ return typeof value === "object" && value !== null && "staleFor" in value;
111
+ }
106
112
  var defaultScope = () => {
107
113
  const tenant = tryCtx()?.["tenant"];
108
114
  return tenant?.id ? `tenant:${tenant.id}` : void 0;
@@ -113,15 +119,18 @@ var Cache = class {
113
119
  this.prefix = options.prefix ?? "basalt";
114
120
  this.scope = options.scope === void 0 ? defaultScope : options.scope;
115
121
  this.onMissingScope = options.onMissingScope ?? "global";
122
+ this.now = options.now ?? Date.now;
116
123
  }
117
124
  driver;
118
125
  prefix;
119
126
  scope;
120
127
  onMissingScope;
121
- /** dedupe of in-flight factories — per-process stampede protection */
128
+ now;
129
+ /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
122
130
  pending = /* @__PURE__ */ new Map();
123
131
  async get(key, fallback) {
124
- const value = await this.driver.get(this.key(key));
132
+ const stored = await this.driver.get(this.key(key));
133
+ const value = isEnvelope(stored) ? stored.v : stored;
125
134
  return value === void 0 ? fallback : value;
126
135
  }
127
136
  async put(key, value, ttl) {
@@ -131,12 +140,8 @@ var Cache = class {
131
140
  ttl === void 0 ? void 0 : parseDuration(ttl)
132
141
  );
133
142
  }
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, []);
143
+ async remember(key, ttlOrOptions, factory) {
144
+ return this.rememberWithTags(key, ttlOrOptions, factory, []);
140
145
  }
141
146
  async forget(key) {
142
147
  return this.driver.delete(this.key(key));
@@ -158,22 +163,56 @@ var Cache = class {
158
163
  scopedTags
159
164
  );
160
165
  },
161
- remember: (key, ttl, factory) => this.rememberWithTags(key, ttl, factory, scopedTags),
166
+ remember: (key, ttlOrOptions, factory) => this.rememberWithTags(key, ttlOrOptions, factory, scopedTags),
162
167
  flush: async () => {
163
168
  await this.driver.flushTags(scopedTags);
164
169
  }
165
170
  };
166
171
  }
167
- async rememberWithTags(key, ttl, factory, tags) {
172
+ async rememberWithTags(key, ttlOrOptions, factory, tags) {
168
173
  const fullKey = this.key(key);
169
- const cached = await this.driver.get(fullKey);
170
- if (cached !== void 0) return cached;
174
+ const stored = await this.driver.get(fullKey);
175
+ if (!isSwr(ttlOrOptions)) {
176
+ const cached = isEnvelope(stored) ? stored.v : stored;
177
+ if (cached !== void 0) return cached;
178
+ return this.compute(
179
+ fullKey,
180
+ () => factory(),
181
+ (value) => this.driver.set(fullKey, value, parseDuration(ttlOrOptions), tags)
182
+ );
183
+ }
184
+ const ttlMs = parseDuration(ttlOrOptions.ttl);
185
+ const staleMs = parseDuration(ttlOrOptions.staleFor);
186
+ const store = (value) => {
187
+ const now = this.now();
188
+ const envelope = {
189
+ __swr: 1,
190
+ v: value,
191
+ freshUntil: now + ttlMs,
192
+ staleUntil: now + ttlMs + staleMs
193
+ };
194
+ return this.driver.set(fullKey, envelope, ttlMs + staleMs, tags);
195
+ };
196
+ if (isEnvelope(stored)) {
197
+ const now = this.now();
198
+ if (now < stored.freshUntil) return stored.v;
199
+ if (now < stored.staleUntil) {
200
+ this.revalidate(fullKey, () => factory(), store);
201
+ return stored.v;
202
+ }
203
+ } else if (stored !== void 0) {
204
+ return stored;
205
+ }
206
+ return this.compute(fullKey, () => factory(), store);
207
+ }
208
+ /** Blocking cache-aside compute with per-key stampede dedupe. */
209
+ compute(fullKey, factory, store) {
171
210
  const inFlight = this.pending.get(fullKey);
172
211
  if (inFlight) return inFlight;
173
212
  const computation = (async () => {
174
213
  try {
175
214
  const value = await factory();
176
- await this.driver.set(fullKey, value, parseDuration(ttl), tags);
215
+ await store(value);
177
216
  return value;
178
217
  } finally {
179
218
  this.pending.delete(fullKey);
@@ -182,6 +221,20 @@ var Cache = class {
182
221
  this.pending.set(fullKey, computation);
183
222
  return computation;
184
223
  }
224
+ /** Fire-and-forget SWR refresh: one per key, failures keep serving stale. */
225
+ revalidate(fullKey, factory, store) {
226
+ if (this.pending.has(fullKey)) return;
227
+ const computation = (async () => {
228
+ try {
229
+ const value = await factory();
230
+ await store(value);
231
+ } finally {
232
+ this.pending.delete(fullKey);
233
+ }
234
+ })();
235
+ this.pending.set(fullKey, computation);
236
+ void computation.catch(() => void 0);
237
+ }
185
238
  root() {
186
239
  if (this.scope === null) return `${this.prefix}:`;
187
240
  const scope = this.scope();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/cache",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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",