@basaltkit/cache 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machize Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,255 @@
1
+ # @basaltkit/cache
2
+
3
+ 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.
4
+
5
+ ## What this module solves
6
+
7
+ **Cache** is temporary memory: instead of fetching the same information from the database (or an external API) on every request, you store the result once and reuse it for a period of time. That period is called the **TTL** (*time to live*) — once it passes, the value expires and is recomputed.
8
+
9
+ This module gives you a `Cache` class with a simple API (`get`, `put`, `remember`, `forget`, `flush`, `tags`) that works over two interchangeable **drivers**: **memory** (inside the Node.js process itself — ideal for development and testing) and **Redis** (an external cache server, shared across multiple processes — ideal for production).
10
+
11
+ It also solves three problems that are normally a hassle:
12
+
13
+ 1. **Tenant isolation** — in a multi-tenant SaaS application (several customers/organizations in the same app), each tenant sees only its own cache entries, automatically, without you having to compose keys by hand.
14
+ 2. **Stampede protection** — if 100 requests arrive at the same time and the value is not cached, the expensive function runs **exactly once**; the other 99 requests wait and receive the same result.
15
+ 3. **Tag-based invalidation** — you can group related entries (e.g. everything related to "plans") and clear them all at once with a single line.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pnpm add @basaltkit/cache
21
+ ```
22
+
23
+ The package depends on `@basaltkit/core` (the framework core) and already includes the Redis client (`ioredis`) — you don't need to install anything else.
24
+
25
+ ## Getting started in 5 minutes
26
+
27
+ Step by step, from zero to having a working cache:
28
+
29
+ 1. **Create the app and register the plugin.** `cachePlugin` registers a `Cache` instance in the application's dependency container (the "container" is where Basalt keeps its shared services).
30
+
31
+ 2. **Get the cache via the `CACHE` token** and use it.
32
+
33
+ ```ts
34
+ import { createApp } from '@basaltkit/core'
35
+ import { CACHE, cachePlugin } from '@basaltkit/cache'
36
+
37
+ // 1. Register the plugin (driver 'memory' — no external servers needed)
38
+ const app = await createApp({
39
+ plugins: [cachePlugin({ driver: 'memory' })],
40
+ }).boot()
41
+
42
+ // 2. Get the Cache instance
43
+ const cache = app.container.get(CACHE)
44
+
45
+ // 3. Store a value for 5 minutes
46
+ await cache.put('greeting', 'hello world', '5m')
47
+
48
+ // 4. Read the value (returns undefined if it doesn't exist or has expired)
49
+ console.log(await cache.get('greeting')) // 'hello world'
50
+
51
+ // 5. At the end of the application, shut everything down (the driver is disconnected)
52
+ await app.shutdown()
53
+ ```
54
+
55
+ For production with Redis, just change the plugin options:
56
+
57
+ ```ts
58
+ import { cachePlugin } from '@basaltkit/cache'
59
+
60
+ cachePlugin({ driver: 'redis', url: 'redis://localhost:6379' })
61
+ ```
62
+
63
+ ## Usage guide
64
+
65
+ ### Reading and writing values (`get` / `put`)
66
+
67
+ ```ts
68
+ import { Cache, MemoryCacheDriver } from '@basaltkit/cache'
69
+
70
+ const cache = new Cache(new MemoryCacheDriver())
71
+
72
+ await cache.put('config', { theme: 'dark' }) // no TTL: stays until deleted
73
+ await cache.put('session', 'abc123', '30s') // expires in 30 seconds
74
+ await cache.put('token', 'xyz', 60_000) // TTL also accepts milliseconds
75
+
76
+ await cache.get('config') // { theme: 'dark' }
77
+ await cache.get('missing') // undefined
78
+ await cache.get('missing', 'default-value') // 'default-value' (fallback)
79
+ ```
80
+
81
+ TTLs accept a number in milliseconds **or** a human-readable string: `'500ms'`, `'30s'`, `'5m'`, `'2h'`, `'7d'`.
82
+
83
+ ### `remember` — the most useful pattern (cache-aside in one line)
84
+
85
+ Instead of writing "check if it's cached; if not, compute and store it", `remember` does all of that for you — with stampede protection (concurrent calls for the same key share **one** execution of the function):
86
+
87
+ ```ts
88
+ import { Cache, MemoryCacheDriver } from '@basaltkit/cache'
89
+
90
+ const cache = new Cache(new MemoryCacheDriver())
91
+
92
+ const plans = await cache.remember('plans', '1h', async () => {
93
+ // This function only runs when the value is NOT cached.
94
+ return fetchPlansFromDatabase()
95
+ })
96
+ ```
97
+
98
+ ### Deleting entries (`forget` / `flush`)
99
+
100
+ ```ts
101
+ await cache.forget('plans') // deletes a key; returns true if it existed
102
+ await cache.flush() // deletes ALL keys in this prefix/scope
103
+ // (never wipes the whole Redis instance — only your keys)
104
+ ```
105
+
106
+ ### Tags — invalidating groups of entries
107
+
108
+ A **tag** is a label that associates several entries with the same group. When the source data changes, you invalidate the whole group:
109
+
110
+ ```ts
111
+ import { Cache, MemoryCacheDriver } from '@basaltkit/cache'
112
+
113
+ const cache = new Cache(new MemoryCacheDriver())
114
+
115
+ await cache.tags('plans').put('plan:free', { price: 0 })
116
+ await cache.tags('plans').put('plan:pro', { price: 29 })
117
+ await cache.put('something-else', 'stays')
118
+
119
+ // remember also works with tags:
120
+ await cache.tags('plans').remember('plan:enterprise', '1h', () => fetchPlan('enterprise'))
121
+
122
+ // Someone changed the plans? Invalidate the whole group:
123
+ await cache.tags('plans').flush()
124
+
125
+ await cache.get('plan:free') // undefined
126
+ await cache.get('something-else') // 'stays' (didn't have the tag)
127
+ ```
128
+
129
+ ### Automatic tenant isolation
130
+
131
+ If your application uses Basalt's tenancy system, every cache operation reads the tenant from the **request context** (`ctx().tenant.id`) and prefixes keys with `tenant:<id>`. Each tenant thus gets its own "drawer" — no extra code required:
132
+
133
+ ```ts
134
+ import { runWithContext } from '@basaltkit/core'
135
+ import { Cache, MemoryCacheDriver } from '@basaltkit/cache'
136
+
137
+ const cache = new Cache(new MemoryCacheDriver())
138
+
139
+ await runWithContext({ tenant: { id: 'acme' } }, () => cache.put('config', 'from-acme'))
140
+ await runWithContext({ tenant: { id: 'globex' } }, () => cache.put('config', 'from-globex'))
141
+ await cache.put('config', 'central') // outside any tenant
142
+
143
+ await runWithContext({ tenant: { id: 'acme' } }, () => cache.get('config')) // 'from-acme'
144
+ await cache.get('config') // 'central'
145
+
146
+ // flush() on one tenant does not touch other tenants or the central space
147
+ await runWithContext({ tenant: { id: 'acme' } }, () => cache.flush())
148
+ ```
149
+
150
+ In normal HTTP requests you don't need to call `runWithContext` — the framework does it for you. To disable isolation, pass `scope: null` in the options.
151
+
152
+ ### Using an existing Redis driver (Advanced)
153
+
154
+ ```ts
155
+ import { Redis } from 'ioredis'
156
+ import { Cache, RedisCacheDriver } from '@basaltkit/cache'
157
+
158
+ // From a URL:
159
+ const cacheA = new Cache(RedisCacheDriver.fromUrl('redis://localhost:6379'))
160
+
161
+ // Or reusing your own ioredis connection:
162
+ const redis = new Redis('redis://localhost:6379')
163
+ const cacheB = new Cache(new RedisCacheDriver(redis))
164
+ ```
165
+
166
+ ## API reference
167
+
168
+ ### `class Cache`
169
+
170
+ `new Cache(driver: CacheDriver, options?: CacheOptions)`
171
+
172
+ | Method | Signature | Description |
173
+ |---|---|---|
174
+ | `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
+ | `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. |
177
+ | `forget` | `forget(key: string): Promise<boolean>` | Deletes a key; `true` if it existed. |
178
+ | `flush` | `flush(): Promise<void>` | Deletes all keys in the current prefix/scope. |
179
+ | `tags` | `tags(...tags: string[])` | Returns an object with `put`, `remember` and `flush` scoped to the given tags. |
180
+
181
+ #### `CacheOptions`
182
+
183
+ | Option | Type | Required? | Default | Description |
184
+ |---|---|---|---|---|
185
+ | `prefix` | `string` | No | `'basalt'` | Root prefix for all keys. |
186
+ | `scope` | `(() => string \| undefined) \| null` | No | reads `ctx().tenant.id` → `tenant:<id>` | Dynamic prefix segment, resolved on each operation. `null` disables tenant isolation. |
187
+
188
+ ### `cachePlugin(options?: CachePluginOptions)`
189
+
190
+ Registers `Cache` in the container under the `CACHE` token and disconnects the driver on application `shutdown`.
191
+
192
+ #### `CachePluginOptions` (extends `CacheOptions`)
193
+
194
+ | Option | Type | Required? | Default | Description |
195
+ |---|---|---|---|---|
196
+ | `driver` | `'memory' \| 'redis'` | No | `'memory'` | Which driver to use. |
197
+ | `url` | `string` | Yes, with `driver: 'redis'` | — | Redis connection URL (e.g. `redis://localhost:6379`). |
198
+ | `prefix`, `scope` | — | No | see `CacheOptions` | Inherited from `CacheOptions`. |
199
+
200
+ ### `CACHE`
201
+
202
+ Dependency injection token: `app.container.get(CACHE)` returns the `Cache` instance.
203
+
204
+ ### `interface CacheDriver` (Advanced)
205
+
206
+ Contract that any driver must implement — implement it to create your own storage:
207
+
208
+ | Method | Signature | Description |
209
+ |---|---|---|
210
+ | `get` | `get(key: string): Promise<unknown>` | `undefined` on miss/expiration. |
211
+ | `set` | `set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void>` | Stores the value (TTL in milliseconds). |
212
+ | `delete` | `delete(key: string): Promise<boolean>` | Deletes a key. |
213
+ | `flushPrefix` | `flushPrefix(prefix: string): Promise<void>` | Deletes all keys starting with the prefix. |
214
+ | `flushTags` | `flushTags(tags: string[]): Promise<void>` | Deletes all keys associated with any of the tags. |
215
+ | `disconnect` | `disconnect(): Promise<void>` | Releases resources/connections. |
216
+
217
+ ### `class MemoryCacheDriver` (Advanced)
218
+
219
+ `new MemoryCacheDriver()` — stores everything in a `Map` inside the process. No options. Perfect for development and testing; data is lost when the process ends and is not shared across processes.
220
+
221
+ ### `class RedisCacheDriver` (Advanced)
222
+
223
+ | Member | Signature | Description |
224
+ |---|---|---|
225
+ | constructor | `new RedisCacheDriver(redis: Redis)` | Takes an already-created `ioredis` instance. |
226
+ | `fromUrl` | `static fromUrl(url: string): RedisCacheDriver` | Creates the connection from a URL. |
227
+
228
+ Values are serialized with `JSON.stringify`/`JSON.parse` — you can only store JSON-serializable values (no functions, `Date` becomes a string, etc.).
229
+
230
+ ## Common errors and solutions (FAQ)
231
+
232
+ **`get` always returns `undefined` after I did a `put`.**
233
+ The `put` and `get` probably ran in different tenant contexts (or one inside and one outside a tenant) — the keys end up under different prefixes. Check the context, or pass `scope: null` if you don't want isolation.
234
+
235
+ **`DURATION_INVALID` error when passing a TTL.**
236
+ The TTL must be a number (milliseconds) or a string in the format `'500ms'`, `'30s'`, `'5m'`, `'2h'`, `'7d'`. `'5 minutos'` or `'1w'` are not accepted.
237
+
238
+ **I stored an object in Redis and got back something "different".**
239
+ The Redis driver serializes to JSON. Class instances, `Date`, `Map`, functions — all of that is lost or turned into a JSON representation. Store plain data (objects, arrays, strings, numbers, booleans).
240
+
241
+ **Stampede protection doesn't work across servers.**
242
+ This is by design: `remember`'s deduplication is **per process** (it uses an in-memory map of in-flight promises). Two different servers can run the factory at the same time — but within each server it runs only once.
243
+
244
+ **`flush()` deleted less than I expected.**
245
+ `flush()` only deletes keys under the current prefix + scope (that's the safety guarantee: it never does `FLUSHALL` on Redis). To clear a tenant's keys, call `flush()` inside that tenant's context.
246
+
247
+ **I configured `driver: 'redis'` and the application fails to boot/use the cache.**
248
+ With `driver: 'redis'`, the `url` option is required. Also verify that the Redis server is reachable at that URL.
249
+
250
+ ## How it connects to other modules
251
+
252
+ - **`@basaltkit/core`** — provides `createApp`, the dependency container, the request context (`ctx`/`runWithContext`) from which tenant isolation comes, and the `parseDuration` used for TTLs.
253
+ - **`@basaltkit/tenancy`** — when the tenancy plugin identifies the request's tenant and puts it in the context, the cache automatically starts isolating keys per tenant.
254
+ - **`@basaltkit/prisma`** — pairs well with `cache.remember(...)` to store the results of expensive database queries.
255
+ - **`@basaltkit/flags`, `@basaltkit/permissions`, etc.** — any module can get the cache via `container.get(CACHE)` to speed up its own operations.
@@ -0,0 +1,86 @@
1
+ import * as _basaltkit_core from '@basaltkit/core';
2
+ import { DurationInput } from '@basaltkit/core';
3
+ import { Redis } from 'ioredis';
4
+
5
+ /** Cache driver contract. Every driver passes the same conformance suite. */
6
+ interface CacheDriver {
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
+ interface CacheOptions {
41
+ /** Root prefix for all keys. Default: 'basalt' */
42
+ prefix?: string;
43
+ /**
44
+ * Dynamic segment of the prefix, resolved on every operation. The default reads
45
+ * `ctx().tenant.id` — automatic per-tenant isolation. Pass `null` to disable.
46
+ */
47
+ scope?: (() => string | undefined) | null;
48
+ }
49
+ declare class Cache {
50
+ private readonly driver;
51
+ private readonly prefix;
52
+ private readonly scope;
53
+ /** dedupe of in-flight factories — per-process stampede protection */
54
+ private readonly pending;
55
+ constructor(driver: CacheDriver, options?: CacheOptions);
56
+ get<T>(key: string): Promise<T | undefined>;
57
+ get<T>(key: string, fallback: T): Promise<T>;
58
+ put(key: string, value: unknown, ttl?: DurationInput): Promise<void>;
59
+ /**
60
+ * One-line cache-aside, with stampede protection: concurrent calls
61
+ * for the same key share ONE execution of the factory.
62
+ */
63
+ remember<T>(key: string, ttl: DurationInput, factory: () => Promise<T> | T): Promise<T>;
64
+ forget(key: string): Promise<boolean>;
65
+ /** Clears only the keys under this prefix/scope — never the entire Redis. */
66
+ flush(): Promise<void>;
67
+ /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
68
+ tags(...tags: string[]): {
69
+ put: (key: string, value: unknown, ttl?: DurationInput) => Promise<void>;
70
+ remember: <T>(key: string, ttl: DurationInput, factory: () => Promise<T> | T) => Promise<T>;
71
+ flush: () => Promise<void>;
72
+ };
73
+ private rememberWithTags;
74
+ private root;
75
+ private key;
76
+ }
77
+ declare const CACHE: _basaltkit_core.Token<Cache>;
78
+ interface CachePluginOptions extends CacheOptions {
79
+ /** 'memory' (default), 'redis' (needs `url`), or a custom `CacheDriver` instance. */
80
+ driver?: 'memory' | 'redis' | CacheDriver;
81
+ /** Required with the 'redis' driver. */
82
+ url?: string;
83
+ }
84
+ declare function cachePlugin(options?: CachePluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
85
+
86
+ export { CACHE, Cache, type CacheDriver, type CacheOptions, type CachePluginOptions, MemoryCacheDriver, RedisCacheDriver, cachePlugin };
package/dist/index.js ADDED
@@ -0,0 +1,203 @@
1
+ // src/index.ts
2
+ import {
3
+ createToken,
4
+ definePlugin,
5
+ parseDuration,
6
+ tryCtx
7
+ } from "@basaltkit/core";
8
+
9
+ // src/drivers/memory.ts
10
+ var MemoryCacheDriver = class {
11
+ store = /* @__PURE__ */ new Map();
12
+ async get(key) {
13
+ const entry = this.store.get(key);
14
+ if (!entry) return void 0;
15
+ if (entry.expiresAt !== void 0 && Date.now() >= entry.expiresAt) {
16
+ this.store.delete(key);
17
+ return void 0;
18
+ }
19
+ return entry.value;
20
+ }
21
+ async set(key, value, ttlMs, tags = []) {
22
+ this.store.set(key, {
23
+ value,
24
+ ...ttlMs !== void 0 ? { expiresAt: Date.now() + ttlMs } : {},
25
+ tags: new Set(tags)
26
+ });
27
+ }
28
+ async delete(key) {
29
+ return this.store.delete(key);
30
+ }
31
+ async flushPrefix(prefix) {
32
+ for (const key of this.store.keys()) {
33
+ if (key.startsWith(prefix)) this.store.delete(key);
34
+ }
35
+ }
36
+ async flushTags(tags) {
37
+ for (const [key, entry] of this.store) {
38
+ if (tags.some((tag) => entry.tags.has(tag))) this.store.delete(key);
39
+ }
40
+ }
41
+ async disconnect() {
42
+ this.store.clear();
43
+ }
44
+ };
45
+
46
+ // src/drivers/redis.ts
47
+ import { Redis } from "ioredis";
48
+ var TAG_PREFIX = "__tags__:";
49
+ var RedisCacheDriver = class _RedisCacheDriver {
50
+ constructor(redis) {
51
+ this.redis = redis;
52
+ }
53
+ redis;
54
+ static fromUrl(url) {
55
+ return new _RedisCacheDriver(new Redis(url));
56
+ }
57
+ async get(key) {
58
+ const raw = await this.redis.get(key);
59
+ return raw === null ? void 0 : JSON.parse(raw);
60
+ }
61
+ async set(key, value, ttlMs, tags = []) {
62
+ const raw = JSON.stringify(value);
63
+ if (ttlMs !== void 0) {
64
+ await this.redis.set(key, raw, "PX", Math.max(1, Math.ceil(ttlMs)));
65
+ } else {
66
+ await this.redis.set(key, raw);
67
+ }
68
+ for (const tag of tags) {
69
+ await this.redis.sadd(TAG_PREFIX + tag, key);
70
+ }
71
+ }
72
+ async delete(key) {
73
+ return await this.redis.del(key) > 0;
74
+ }
75
+ async flushPrefix(prefix) {
76
+ let cursor = "0";
77
+ do {
78
+ const [next, keys] = await this.redis.scan(cursor, "MATCH", `${prefix}*`, "COUNT", 200);
79
+ cursor = next;
80
+ if (keys.length > 0) await this.redis.del(...keys);
81
+ } while (cursor !== "0");
82
+ }
83
+ async flushTags(tags) {
84
+ for (const tag of tags) {
85
+ const tagKey = TAG_PREFIX + tag;
86
+ const keys = await this.redis.smembers(tagKey);
87
+ if (keys.length > 0) await this.redis.del(...keys);
88
+ await this.redis.del(tagKey);
89
+ }
90
+ }
91
+ async disconnect() {
92
+ await this.redis.quit();
93
+ }
94
+ };
95
+
96
+ // src/index.ts
97
+ var defaultScope = () => {
98
+ const tenant = tryCtx()?.["tenant"];
99
+ return tenant?.id ? `tenant:${tenant.id}` : void 0;
100
+ };
101
+ var Cache = class {
102
+ constructor(driver, options = {}) {
103
+ this.driver = driver;
104
+ this.prefix = options.prefix ?? "basalt";
105
+ this.scope = options.scope === void 0 ? defaultScope : options.scope;
106
+ }
107
+ driver;
108
+ prefix;
109
+ scope;
110
+ /** dedupe of in-flight factories — per-process stampede protection */
111
+ pending = /* @__PURE__ */ new Map();
112
+ async get(key, fallback) {
113
+ const value = await this.driver.get(this.key(key));
114
+ return value === void 0 ? fallback : value;
115
+ }
116
+ async put(key, value, ttl) {
117
+ await this.driver.set(
118
+ this.key(key),
119
+ value,
120
+ ttl === void 0 ? void 0 : parseDuration(ttl)
121
+ );
122
+ }
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, []);
129
+ }
130
+ async forget(key) {
131
+ return this.driver.delete(this.key(key));
132
+ }
133
+ /** Clears only the keys under this prefix/scope — never the entire Redis. */
134
+ async flush() {
135
+ await this.driver.flushPrefix(this.root());
136
+ }
137
+ /** Tag-scoped operations: `cache.tags('plans').flush()` invalidates the group. */
138
+ tags(...tags) {
139
+ const scopedTags = tags.map((tag) => `${this.root()}${tag}`);
140
+ return {
141
+ put: async (key, value, ttl) => {
142
+ await this.driver.set(
143
+ this.key(key),
144
+ value,
145
+ ttl === void 0 ? void 0 : parseDuration(ttl),
146
+ scopedTags
147
+ );
148
+ },
149
+ remember: (key, ttl, factory) => this.rememberWithTags(key, ttl, factory, scopedTags),
150
+ flush: async () => {
151
+ await this.driver.flushTags(scopedTags);
152
+ }
153
+ };
154
+ }
155
+ async rememberWithTags(key, ttl, factory, tags) {
156
+ const fullKey = this.key(key);
157
+ const cached = await this.driver.get(fullKey);
158
+ if (cached !== void 0) return cached;
159
+ const inFlight = this.pending.get(fullKey);
160
+ if (inFlight) return inFlight;
161
+ const computation = (async () => {
162
+ try {
163
+ const value = await factory();
164
+ await this.driver.set(fullKey, value, parseDuration(ttl), tags);
165
+ return value;
166
+ } finally {
167
+ this.pending.delete(fullKey);
168
+ }
169
+ })();
170
+ this.pending.set(fullKey, computation);
171
+ return computation;
172
+ }
173
+ root() {
174
+ const scope = this.scope?.();
175
+ return scope ? `${this.prefix}:${scope}:` : `${this.prefix}:`;
176
+ }
177
+ key(key) {
178
+ return this.root() + key;
179
+ }
180
+ };
181
+ var CACHE = createToken("cache");
182
+ function cachePlugin(options = {}) {
183
+ let driver;
184
+ return definePlugin({
185
+ name: "basalt:cache",
186
+ register({ container }) {
187
+ container.singleton(CACHE, () => {
188
+ driver = typeof options.driver === "object" ? options.driver : options.driver === "redis" ? RedisCacheDriver.fromUrl(options.url) : new MemoryCacheDriver();
189
+ return new Cache(driver, options);
190
+ });
191
+ },
192
+ async shutdown() {
193
+ await driver?.disconnect();
194
+ }
195
+ });
196
+ }
197
+ export {
198
+ CACHE,
199
+ Cache,
200
+ MemoryCacheDriver,
201
+ RedisCacheDriver,
202
+ cachePlugin
203
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@basaltkit/cache",
3
+ "version": "1.0.0",
4
+ "description": "Basalt cache layer: Redis and Memory drivers, tags, TTL, stampede protection and automatic per-tenant isolation.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "dependencies": {
17
+ "ioredis": "^5.6.0",
18
+ "@basaltkit/core": "^1.0.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.15.0",
22
+ "tsup": "^8.4.0",
23
+ "typescript": "^5.8.0",
24
+ "vitest": "^3.1.0",
25
+ "@basaltkit/tsconfig": "^0.24.0"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Zebedeu/basalt.git",
33
+ "directory": "packages/cache"
34
+ },
35
+ "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/cache#readme",
36
+ "bugs": "https://github.com/Zebedeu/basalt/issues",
37
+ "keywords": [
38
+ "basalt",
39
+ "typescript",
40
+ "cache",
41
+ "redis"
42
+ ],
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format esm --dts --clean",
45
+ "test": "vitest run",
46
+ "typecheck": "tsc --noEmit"
47
+ }
48
+ }