@basaltkit/cache 1.2.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 +6 -0
- 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 +12 -48
- package/dist/index.js +169 -261
- 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.
|
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
|
/**
|
|
@@ -61,7 +27,7 @@ interface CacheOptions {
|
|
|
61
27
|
now?: () => number;
|
|
62
28
|
}
|
|
63
29
|
/** SwrOptions turns `remember` into a stale-while-revalidate read. */
|
|
64
|
-
interface SwrOptions {
|
|
30
|
+
export interface SwrOptions {
|
|
65
31
|
/** How long the value stays fresh (served without revalidation). */
|
|
66
32
|
ttl: DurationInput;
|
|
67
33
|
/**
|
|
@@ -71,7 +37,7 @@ interface SwrOptions {
|
|
|
71
37
|
*/
|
|
72
38
|
staleFor: DurationInput;
|
|
73
39
|
}
|
|
74
|
-
declare class Cache {
|
|
40
|
+
export declare class Cache {
|
|
75
41
|
private readonly driver;
|
|
76
42
|
private readonly prefix;
|
|
77
43
|
private readonly scope;
|
|
@@ -106,13 +72,11 @@ declare class Cache {
|
|
|
106
72
|
private root;
|
|
107
73
|
private key;
|
|
108
74
|
}
|
|
109
|
-
declare const CACHE:
|
|
110
|
-
interface CachePluginOptions extends CacheOptions {
|
|
75
|
+
export declare const CACHE: import("@basaltkit/core").Token<Cache>;
|
|
76
|
+
export interface CachePluginOptions extends CacheOptions {
|
|
111
77
|
/** 'memory' (default), 'redis' (needs `url`), or a custom `CacheDriver` instance. */
|
|
112
78
|
driver?: 'memory' | 'redis' | CacheDriver;
|
|
113
79
|
/** Required with the 'redis' driver. */
|
|
114
80
|
url?: string;
|
|
115
81
|
}
|
|
116
|
-
declare function cachePlugin(options?: CachePluginOptions):
|
|
117
|
-
|
|
118
|
-
export { CACHE, Cache, type CacheDriver, type CacheOptions, type CachePluginOptions, MemoryCacheDriver, MissingCacheScopeError, RedisCacheDriver, type SwrOptions, cachePlugin };
|
|
82
|
+
export declare function cachePlugin(options?: CachePluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
|
package/dist/index.js
CHANGED
|
@@ -1,271 +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
|
-
async set(key, value, ttlMs, tags = []) {
|
|
23
|
-
this.store.set(key, {
|
|
24
|
-
value,
|
|
25
|
-
...ttlMs !== void 0 ? { expiresAt: Date.now() + ttlMs } : {},
|
|
26
|
-
tags: new Set(tags)
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
async delete(key) {
|
|
30
|
-
return this.store.delete(key);
|
|
31
|
-
}
|
|
32
|
-
async flushPrefix(prefix) {
|
|
33
|
-
for (const key of this.store.keys()) {
|
|
34
|
-
if (key.startsWith(prefix)) this.store.delete(key);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
async flushTags(tags) {
|
|
38
|
-
for (const [key, entry] of this.store) {
|
|
39
|
-
if (tags.some((tag) => entry.tags.has(tag))) this.store.delete(key);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
async disconnect() {
|
|
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);
|
|
68
|
-
}
|
|
69
|
-
for (const tag of tags) {
|
|
70
|
-
await this.redis.sadd(TAG_PREFIX + tag, key);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
async delete(key) {
|
|
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);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
async disconnect() {
|
|
93
|
-
await this.redis.quit();
|
|
94
|
-
}
|
|
95
|
-
};
|
|
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
|
-
};
|
|
10
|
+
}
|
|
106
11
|
function isEnvelope(value) {
|
|
107
|
-
|
|
12
|
+
return typeof value === 'object' && value !== null && value.__swr === 1;
|
|
108
13
|
}
|
|
109
14
|
function isSwr(value) {
|
|
110
|
-
|
|
15
|
+
return typeof value === 'object' && value !== null && 'staleFor' in value;
|
|
111
16
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
17
|
+
const defaultScope = () => {
|
|
18
|
+
const tenant = tryCtx()?.['tenant'];
|
|
19
|
+
return tenant?.id ? `tenant:${tenant.id}` : undefined;
|
|
115
20
|
};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
pending = /* @__PURE__ */ new Map();
|
|
131
|
-
async get(key, fallback) {
|
|
132
|
-
const stored = await this.driver.get(this.key(key));
|
|
133
|
-
const value = isEnvelope(stored) ? stored.v : stored;
|
|
134
|
-
return value === void 0 ? fallback : value;
|
|
135
|
-
}
|
|
136
|
-
async put(key, value, ttl) {
|
|
137
|
-
await this.driver.set(
|
|
138
|
-
this.key(key),
|
|
139
|
-
value,
|
|
140
|
-
ttl === void 0 ? void 0 : parseDuration(ttl)
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
async remember(key, ttlOrOptions, factory) {
|
|
144
|
-
return this.rememberWithTags(key, ttlOrOptions, factory, []);
|
|
145
|
-
}
|
|
146
|
-
async forget(key) {
|
|
147
|
-
return this.driver.delete(this.key(key));
|
|
148
|
-
}
|
|
149
|
-
/** Clears only the keys under this prefix/scope — never the entire Redis. */
|
|
150
|
-
async flush() {
|
|
151
|
-
if (this.scope !== null && this.scope() === void 0) throw new MissingCacheScopeError("flush");
|
|
152
|
-
await this.driver.flushPrefix(this.root());
|
|
153
|
-
}
|
|
154
|
-
/** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
|
|
155
|
-
tags(...tags) {
|
|
156
|
-
const scopedTags = tags.map((tag) => `${this.root()}${tag}`);
|
|
157
|
-
return {
|
|
158
|
-
put: async (key, value, ttl) => {
|
|
159
|
-
await this.driver.set(
|
|
160
|
-
this.key(key),
|
|
161
|
-
value,
|
|
162
|
-
ttl === void 0 ? void 0 : parseDuration(ttl),
|
|
163
|
-
scopedTags
|
|
164
|
-
);
|
|
165
|
-
},
|
|
166
|
-
remember: (key, ttlOrOptions, factory) => this.rememberWithTags(key, ttlOrOptions, factory, scopedTags),
|
|
167
|
-
flush: async () => {
|
|
168
|
-
await this.driver.flushTags(scopedTags);
|
|
169
|
-
}
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
async rememberWithTags(key, ttlOrOptions, factory, tags) {
|
|
173
|
-
const fullKey = this.key(key);
|
|
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
|
-
);
|
|
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;
|
|
183
35
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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;
|
|
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;
|
|
205
40
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
41
|
+
async put(key, value, ttl) {
|
|
42
|
+
await this.driver.set(this.key(key), value, ttl === undefined ? undefined : parseDuration(ttl));
|
|
43
|
+
}
|
|
44
|
+
async remember(key, ttlOrOptions, factory) {
|
|
45
|
+
return this.rememberWithTags(key, ttlOrOptions, factory, []);
|
|
46
|
+
}
|
|
47
|
+
async forget(key) {
|
|
48
|
+
return this.driver.delete(this.key(key));
|
|
49
|
+
}
|
|
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;
|
|
261
157
|
}
|
|
262
|
-
});
|
|
263
158
|
}
|
|
264
|
-
export
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.2.
|
|
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
|
}
|