@basaltkit/cache 1.0.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Machize Contributors
3
+ Copyright (c) 2026 Basalt Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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
@@ -1,5 +1,5 @@
1
1
  import * as _basaltkit_core from '@basaltkit/core';
2
- import { DurationInput } from '@basaltkit/core';
2
+ import { DurationInput, BasaltError } from '@basaltkit/core';
3
3
  import { Redis } from 'ioredis';
4
4
 
5
5
  /** Cache driver contract. Every driver passes the same conformance suite. */
@@ -37,20 +37,47 @@ declare class RedisCacheDriver implements CacheDriver {
37
37
  disconnect(): Promise<void>;
38
38
  }
39
39
 
40
+ declare class MissingCacheScopeError extends BasaltError {
41
+ constructor(op: string);
42
+ }
40
43
  interface CacheOptions {
41
44
  /** Root prefix for all keys. Default: 'basalt' */
42
45
  prefix?: string;
43
46
  /**
44
47
  * Dynamic segment of the prefix, resolved on every operation. The default reads
45
- * `ctx().tenant.id` — automatic per-tenant isolation. Pass `null` to disable.
48
+ * `ctx().tenant.id` — automatic per-tenant isolation. Pass `null` to disable
49
+ * (a deliberate global cache).
46
50
  */
47
51
  scope?: (() => string | undefined) | null;
52
+ /**
53
+ * What to do when the scope function resolves nothing (no tenant in context):
54
+ * `'global'` (default) shares one namespace — convenient but a per-tenant value
55
+ * cached without a tenant leaks to others; `'error'` fails closed (throws
56
+ * {@link MissingCacheScopeError}) on read/write. `flush()` ALWAYS fails closed
57
+ * regardless, so a mis-scoped call can't wipe every tenant's cache.
58
+ */
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;
48
73
  }
49
74
  declare class Cache {
50
75
  private readonly driver;
51
76
  private readonly prefix;
52
77
  private readonly scope;
53
- /** dedupe of in-flight factories — per-process stampede protection */
78
+ private readonly onMissingScope;
79
+ private readonly now;
80
+ /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
54
81
  private readonly pending;
55
82
  constructor(driver: CacheDriver, options?: CacheOptions);
56
83
  get<T>(key: string): Promise<T | undefined>;
@@ -61,16 +88,21 @@ declare class Cache {
61
88
  * for the same key share ONE execution of the factory.
62
89
  */
63
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>;
64
92
  forget(key: string): Promise<boolean>;
65
93
  /** Clears only the keys under this prefix/scope — never the entire Redis. */
66
94
  flush(): Promise<void>;
67
95
  /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
68
96
  tags(...tags: string[]): {
69
97
  put: (key: string, value: unknown, ttl?: DurationInput) => Promise<void>;
70
- 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>;
71
99
  flush: () => Promise<void>;
72
100
  };
73
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;
74
106
  private root;
75
107
  private key;
76
108
  }
@@ -83,4 +115,4 @@ interface CachePluginOptions extends CacheOptions {
83
115
  }
84
116
  declare function cachePlugin(options?: CachePluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
85
117
 
86
- export { CACHE, Cache, type CacheDriver, type CacheOptions, type CachePluginOptions, MemoryCacheDriver, RedisCacheDriver, cachePlugin };
118
+ export { CACHE, Cache, type CacheDriver, type CacheOptions, type CachePluginOptions, MemoryCacheDriver, MissingCacheScopeError, RedisCacheDriver, type SwrOptions, cachePlugin };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/index.ts
2
2
  import {
3
+ BasaltError,
3
4
  createToken,
4
5
  definePlugin,
5
6
  parseDuration,
@@ -94,6 +95,20 @@ var RedisCacheDriver = class _RedisCacheDriver {
94
95
  };
95
96
 
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
+ 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
+ }
97
112
  var defaultScope = () => {
98
113
  const tenant = tryCtx()?.["tenant"];
99
114
  return tenant?.id ? `tenant:${tenant.id}` : void 0;
@@ -103,14 +118,19 @@ var Cache = class {
103
118
  this.driver = driver;
104
119
  this.prefix = options.prefix ?? "basalt";
105
120
  this.scope = options.scope === void 0 ? defaultScope : options.scope;
121
+ this.onMissingScope = options.onMissingScope ?? "global";
122
+ this.now = options.now ?? Date.now;
106
123
  }
107
124
  driver;
108
125
  prefix;
109
126
  scope;
110
- /** dedupe of in-flight factories — per-process stampede protection */
127
+ onMissingScope;
128
+ now;
129
+ /** dedupe of in-flight factories — per-process stampede protection (also dedupes SWR revalidation) */
111
130
  pending = /* @__PURE__ */ new Map();
112
131
  async get(key, fallback) {
113
- 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;
114
134
  return value === void 0 ? fallback : value;
115
135
  }
116
136
  async put(key, value, ttl) {
@@ -120,18 +140,15 @@ var Cache = class {
120
140
  ttl === void 0 ? void 0 : parseDuration(ttl)
121
141
  );
122
142
  }
123
- /**
124
- * One-line cache-aside, with stampede protection: concurrent calls
125
- * for the same key share ONE execution of the factory.
126
- */
127
- async remember(key, ttl, factory) {
128
- return this.rememberWithTags(key, ttl, factory, []);
143
+ async remember(key, ttlOrOptions, factory) {
144
+ return this.rememberWithTags(key, ttlOrOptions, factory, []);
129
145
  }
130
146
  async forget(key) {
131
147
  return this.driver.delete(this.key(key));
132
148
  }
133
149
  /** Clears only the keys under this prefix/scope — never the entire Redis. */
134
150
  async flush() {
151
+ if (this.scope !== null && this.scope() === void 0) throw new MissingCacheScopeError("flush");
135
152
  await this.driver.flushPrefix(this.root());
136
153
  }
137
154
  /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
@@ -146,22 +163,56 @@ var Cache = class {
146
163
  scopedTags
147
164
  );
148
165
  },
149
- remember: (key, ttl, factory) => this.rememberWithTags(key, ttl, factory, scopedTags),
166
+ remember: (key, ttlOrOptions, factory) => this.rememberWithTags(key, ttlOrOptions, factory, scopedTags),
150
167
  flush: async () => {
151
168
  await this.driver.flushTags(scopedTags);
152
169
  }
153
170
  };
154
171
  }
155
- async rememberWithTags(key, ttl, factory, tags) {
172
+ async rememberWithTags(key, ttlOrOptions, factory, tags) {
156
173
  const fullKey = this.key(key);
157
- const cached = await this.driver.get(fullKey);
158
- 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) {
159
210
  const inFlight = this.pending.get(fullKey);
160
211
  if (inFlight) return inFlight;
161
212
  const computation = (async () => {
162
213
  try {
163
214
  const value = await factory();
164
- await this.driver.set(fullKey, value, parseDuration(ttl), tags);
215
+ await store(value);
165
216
  return value;
166
217
  } finally {
167
218
  this.pending.delete(fullKey);
@@ -170,8 +221,24 @@ var Cache = class {
170
221
  this.pending.set(fullKey, computation);
171
222
  return computation;
172
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
+ }
173
238
  root() {
174
- const scope = this.scope?.();
239
+ if (this.scope === null) return `${this.prefix}:`;
240
+ const scope = this.scope();
241
+ if (scope === void 0 && this.onMissingScope === "error") throw new MissingCacheScopeError("operation");
175
242
  return scope ? `${this.prefix}:${scope}:` : `${this.prefix}:`;
176
243
  }
177
244
  key(key) {
@@ -198,6 +265,7 @@ export {
198
265
  CACHE,
199
266
  Cache,
200
267
  MemoryCacheDriver,
268
+ MissingCacheScopeError,
201
269
  RedisCacheDriver,
202
270
  cachePlugin
203
271
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/cache",
3
- "version": "1.0.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",