@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 +22 -1
- package/dist/index.d.ts +22 -3
- package/dist/index.js +66 -13
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
172
|
+
async rememberWithTags(key, ttlOrOptions, factory, tags) {
|
|
168
173
|
const fullKey = this.key(key);
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
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
|
|
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