@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 +28 -1
- package/dist/driver.d.ts +12 -0
- package/dist/driver.js +1 -0
- package/dist/drivers/memory.d.ts +10 -0
- package/dist/drivers/memory.js +38 -0
- package/dist/drivers/redis.d.ts +13 -0
- package/dist/drivers/redis.js +52 -0
- package/dist/index.d.ts +32 -49
- package/dist/index.js +171 -210
- package/package.json +10 -11
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
|
|
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. |
|
package/dist/driver.d.ts
ADDED
|
@@ -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
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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):
|
|
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
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
44
|
+
async remember(key, ttlOrOptions, factory) {
|
|
45
|
+
return this.rememberWithTags(key, ttlOrOptions, factory, []);
|
|
71
46
|
}
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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": "^
|
|
18
|
-
"@basaltkit/core": "^1.
|
|
17
|
+
"ioredis": "^6.0.0",
|
|
18
|
+
"@basaltkit/core": "^1.1.2"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@types/node": "^
|
|
22
|
-
"
|
|
23
|
-
"
|
|
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/
|
|
31
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
33
32
|
"directory": "packages/cache"
|
|
34
33
|
},
|
|
35
|
-
"homepage": "https://github.com/
|
|
36
|
-
"bugs": "https://github.com/
|
|
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": "
|
|
43
|
+
"build": "tsc -p tsconfig.build.json",
|
|
45
44
|
"test": "vitest run",
|
|
46
45
|
"typecheck": "tsc --noEmit"
|
|
47
46
|
}
|