@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 +1 -1
- package/README.md +22 -1
- package/dist/index.d.ts +37 -5
- package/dist/index.js +82 -14
- package/package.json +1 -1
package/LICENSE
CHANGED
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
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
172
|
+
async rememberWithTags(key, ttlOrOptions, factory, tags) {
|
|
156
173
|
const fullKey = this.key(key);
|
|
157
|
-
const
|
|
158
|
-
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) {
|
|
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
|
|
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
|
-
|
|
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