@vercube/cache 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 ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025-present - Vercube
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,54 @@
1
+ <div align="center">
2
+ <img src="https://raw.githubusercontent.com/vercube/vercube/refs/heads/main/.github/assets/cover.png" width="100%" alt="Vercube - Unleash your server development." />
3
+ <br>
4
+ <br>
5
+
6
+ # @vercube/cache
7
+
8
+ ### Decorator driven caching for Vercube apps
9
+
10
+ [![Ask DeepWiki](<https://img.shields.io/badge/ask-deepwiki-%20blue?style=for-the-badge&logo=bookstack&logoColor=rgba(255%2C%20255%2C%20255%2C%200.6)&labelColor=%23000&color=%232f2f2f>)](https://deepwiki.com/vercube/vercube)
11
+ ![NPM Version](<https://img.shields.io/npm/v/%40vercube%2Fcache?style=for-the-badge&logo=npm&logoColor=rgba(255%2C%20255%2C%20255%2C%200.6)&labelColor=%23000&color=%232e2e2e&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vercube%2Fcache>)
12
+ ![GitHub License](<https://img.shields.io/github/license/vercube/vercube?style=for-the-badge&logo=gitbook&logoColor=rgba(255%2C%20255%2C%20255%2C%200.6)&labelColor=%23000&color=%232f2f2f>)
13
+ ![Codecov](<https://img.shields.io/codecov/c/github/vercube/vercube?style=for-the-badge&logo=vitest&logoColor=rgba(255%2C%20255%2C%20255%2C%200.6)&labelColor=%23000&color=%232f2f2f>)
14
+
15
+ **One decorator turns any method into a cached one - with TTL, stale-while-revalidate, call deduplication and precise invalidation. Every entry lives in a regular Vercube storage.**
16
+
17
+ [Website](https://vercube.dev) • [Documentation](https://vercube.dev/docs/getting-started)
18
+
19
+ </div>
20
+
21
+ ## ✨ Features
22
+
23
+ - **`@Cache()` decorator** - cache any method, keyed by its arguments
24
+ - **Backed by `@vercube/storage`** - memory, S3 or your own driver, no glue code
25
+ - **Stale-while-revalidate** - serve instantly, refresh in the background
26
+ - **Call deduplication** - concurrent calls for the same key share one execution
27
+ - **Precise invalidation** - `invalidate()` and `expire()` right on the decorated method
28
+ - **Self-invalidating** - entries are dropped when the method body or its options change
29
+ - **Multi-tier** - read through a fast local storage into a shared one
30
+
31
+ ## 📦 Installation
32
+
33
+ ```bash
34
+ pnpm add @vercube/cache @vercube/storage
35
+ ```
36
+
37
+ ## 📖 Usage
38
+
39
+ ```ts
40
+ import { Cache } from '@vercube/cache';
41
+
42
+ export class UsersService {
43
+ @Cache({ maxAge: 300, swr: true, staleMaxAge: 900 })
44
+ public async getUser(id: string): Promise<User> {
45
+ return this.database.findUser(id);
46
+ }
47
+ }
48
+ ```
49
+
50
+ Check out the full [documentation](https://vercube.dev/docs/modules/cache/overview)
51
+
52
+ ## 📜 License
53
+
54
+ [MIT](https://github.com/vercube/vercube/blob/main/LICENSE)
@@ -0,0 +1,407 @@
1
+ import { BaseDecorator, Container } from "@vercube/di";
2
+ import { Logger } from "@vercube/logger";
3
+ import { Storage, StorageManager } from "@vercube/storage";
4
+ import { CacheEntry, CacheOptions, CacheStatus, StorageInterface } from "ocache";
5
+ //#region src/Services/CacheStorageAdapter.d.ts
6
+ /**
7
+ * Prefix every cache key starts with. The segment that follows it (when present)
8
+ * is the name of the storage mounted in {@link StorageManager} that backs the entry.
9
+ *
10
+ * ocache builds keys as `<base>:<group>:<name>:<key>.json`, so the base is always
11
+ * the first colon separated segment and can be parsed back out on read/write.
12
+ */
13
+ declare const CACHE_BASE_PREFIX = "/cache";
14
+ /**
15
+ * Builds the cache key base for a storage mounted in {@link StorageManager}.
16
+ *
17
+ * @param {string} [storage] - Name of the mounted storage, `default` when omitted
18
+ * @returns {string} The base prefix cache keys for that storage start with
19
+ */
20
+ declare function cacheBaseForStorage(storage?: string): string;
21
+ /**
22
+ * Extracts the mounted storage name back out of a full cache key.
23
+ *
24
+ * @param {string} key - A cache key produced by the caching engine
25
+ * @returns {string} Name of the storage the key belongs to, `default` when the key carries no storage segment
26
+ */
27
+ declare function storageNameFromCacheKey(key: string): string;
28
+ /**
29
+ * Bridges the caching engine onto the Vercube {@link StorageManager}.
30
+ *
31
+ * The cache module owns no storage of its own - every entry lives in a storage
32
+ * mounted in `@vercube/storage`, so a cache can be backed by memory, S3 or any
33
+ * other driver simply by mounting it, and the very same entries are visible
34
+ * through the regular storage API.
35
+ *
36
+ * Every cache key carries the name of the storage it belongs to in its base
37
+ * segment, so a single adapter instance serves cached functions that live in
38
+ * different storages - and even ones that span several of them at once.
39
+ *
40
+ * A cached function may point at a storage that has not been mounted; in that
41
+ * case a {@link MemoryStorage} is mounted under that name on first use so that
42
+ * caching works with zero configuration. Mounting the name yourself - before or
43
+ * after - is what swaps the backend for a real one.
44
+ */
45
+ declare class CacheStorageAdapter implements StorageInterface {
46
+ /** Storage manager holding every mounted storage */
47
+ protected gStorageManager: StorageManager | null;
48
+ /** Logger instance */
49
+ protected gLogger: Logger | null;
50
+ /** In-flight auto-mounts, keyed by storage name, so concurrent calls share one storage */
51
+ protected fMounting: Map<string, Promise<Storage>>;
52
+ /**
53
+ * Reads a cache entry from the storage its key points at.
54
+ *
55
+ * @template T - Type of the stored entry
56
+ * @param {string} key - Full cache key, including the storage base prefix
57
+ * @returns {Promise<T | null>} The stored entry, or null when it is absent
58
+ */
59
+ get<T = unknown>(key: string): Promise<T | null>;
60
+ /**
61
+ * Writes a cache entry to the storage its key points at.
62
+ *
63
+ * The caching engine signals a deletion by writing `null`, which is mapped onto
64
+ * the storage's delete operation so that no empty entries are left behind.
65
+ *
66
+ * @template T - Type of the value to store
67
+ * @param {string} key - Full cache key, including the storage base prefix
68
+ * @param {T} value - Entry to store, or null to remove it
69
+ * @param {{ ttl?: number }} [opts] - Storage hints, `ttl` in seconds
70
+ * @returns {Promise<void>} Resolves once the write is complete
71
+ */
72
+ set<T = unknown>(key: string, value: T, opts?: {
73
+ ttl?: number;
74
+ }): Promise<void>;
75
+ /**
76
+ * Resolves the mounted storage a cache key belongs to, mounting an in-memory
77
+ * one under that name when nothing has been mounted yet.
78
+ *
79
+ * @param {string} key - Full cache key, including the storage base prefix
80
+ * @returns {Promise<Storage>} The storage backing that key
81
+ * @protected
82
+ */
83
+ protected resolveStorage(key: string): Promise<Storage>;
84
+ /**
85
+ * Mounts an in-memory storage under the given name, so that a cached function
86
+ * pointing at a storage nobody mounted still works.
87
+ *
88
+ * @param {StorageManager} manager - Storage manager to mount into
89
+ * @param {string} name - Name of the storage to mount
90
+ * @returns {Promise<Storage>} The freshly mounted storage
91
+ * @protected
92
+ */
93
+ protected mountFallback(manager: StorageManager, name: string): Promise<Storage>;
94
+ }
95
+ //#endregion
96
+ //#region src/Types/CacheTypes.d.ts
97
+ declare namespace CacheTypes {
98
+ /**
99
+ * Name (or ordered list of names) of storages mounted in the {@link StorageManager}
100
+ * that should back a cached function.
101
+ *
102
+ * When an array is given, reads try each storage in order (multi-tier: hit the fast
103
+ * one first, fall back to the shared one) and writes go to all of them.
104
+ *
105
+ * When omitted, the `default` storage is used.
106
+ */
107
+ type StorageRef = string | string[];
108
+ /**
109
+ * How a cached value was served on a given call.
110
+ * - `hit` - a fresh cached value was returned without re-resolving
111
+ * - `stale` - a stale value was served while a background SWR refresh runs
112
+ * - `revalidated` - a prior value existed but was expired, so it was re-resolved in the foreground
113
+ * - `miss` - the value was resolved fresh on this call
114
+ */
115
+ type Status = CacheStatus;
116
+ /**
117
+ * A cache entry as it is stored, wrapping the cached value with its metadata.
118
+ */
119
+ type Entry<T = unknown> = CacheEntry<T>;
120
+ /**
121
+ * Options accepted by {@link CacheManager.cached} and the `@Cache()` decorator.
122
+ *
123
+ * This is the ocache option set with the low level `base` option replaced by the
124
+ * Vercube native `storage` option - `base` is derived from it so that cache keys
125
+ * can be routed to the right mounted storage.
126
+ */
127
+ type Options<T = any, ArgsT extends unknown[] = any[]> = Omit<CacheOptions<T, ArgsT>, 'base'> & {
128
+ /** Storage (or storages, for multi-tier caching) mounted in StorageManager to keep entries in. */
129
+ storage?: StorageRef;
130
+ };
131
+ /**
132
+ * Application wide defaults applied to every cached function.
133
+ * Per-call options always win over these.
134
+ */
135
+ interface Defaults {
136
+ /** Number of seconds an entry stays fresh. @default 1 */
137
+ maxAge?: number;
138
+ /** Serve a stale entry while refreshing it in the background. @default false */
139
+ swr?: boolean;
140
+ /** Maximum number of seconds a stale entry may be served while revalidating. */
141
+ staleMaxAge?: number;
142
+ /** Cache key group prefix. @default 'functions' */
143
+ group?: string;
144
+ /** Default storage (or storages) to keep entries in. */
145
+ storage?: StorageRef;
146
+ /** Called for every cache related error (read, write, background refresh). */
147
+ onError?: (error: unknown) => void;
148
+ }
149
+ /**
150
+ * A function wrapped with caching, augmented with on-demand revalidation helpers.
151
+ */
152
+ type CachedFunction<T = unknown, ArgsT extends unknown[] = any[]> = {
153
+ (...args: ArgsT): Promise<T>;
154
+ /** Resolves every storage key (one per storage tier) the given arguments cache under. */
155
+ resolveKeys: (...args: ArgsT) => Promise<string[]>;
156
+ /** Removes cached entries for the given arguments from every storage tier. */
157
+ invalidate: (...args: ArgsT) => Promise<void>;
158
+ /** Marks cached entries as stale so the next access refreshes them. */
159
+ expire: (...args: ArgsT) => Promise<void>;
160
+ };
161
+ /**
162
+ * Options for the `@Cache()` decorator. Identical to {@link Options}, except that
163
+ * `name` defaults to `ClassName.methodName` when it is not given.
164
+ */
165
+ type DecoratorOptions<T = any, ArgsT extends unknown[] = any[]> = Options<T, ArgsT>;
166
+ /**
167
+ * A method wrapped by the `@Cache()` decorator, as seen from the outside.
168
+ * Use it to type a cached method so its `.invalidate()` / `.expire()` helpers are visible.
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * class UsersService {
173
+ * @Cache({ maxAge: 60 })
174
+ * public getUser!: CacheTypes.CachedMethod<[id: string], User>;
175
+ * }
176
+ * ```
177
+ */
178
+ type CachedMethod<ArgsT extends unknown[] = any[], T = unknown> = CachedFunction<T, ArgsT>;
179
+ }
180
+ //#endregion
181
+ //#region src/Services/CacheManager.d.ts
182
+ /**
183
+ * Central entry point of the cache module.
184
+ *
185
+ * The CacheManager owns the wiring between the caching engine and the storages
186
+ * mounted in `@vercube/storage`, holds the application wide defaults and exposes
187
+ * the imperative API used both directly and by the `@Cache()` decorator.
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * container.bind(CacheManager);
192
+ *
193
+ * const cache = container.get(CacheManager);
194
+ * cache.configure({ maxAge: 60, swr: true, staleMaxAge: 300, storage: 'cache' });
195
+ *
196
+ * const getUser = cache.cached((id: string) => db.users.find(id), { name: 'getUser' });
197
+ *
198
+ * await getUser('123'); // resolves and stores
199
+ * await getUser('123'); // served from cache
200
+ * await getUser.invalidate('123'); // drops the entry
201
+ * ```
202
+ */
203
+ declare class CacheManager {
204
+ /** Container instance */
205
+ protected gContainer: Container;
206
+ /** Logger instance */
207
+ protected gLogger: Logger | null;
208
+ /** Application wide defaults applied to every cached function */
209
+ protected fDefaults: CacheTypes.Defaults;
210
+ /** Storage adapter bridging the caching engine onto the mounted storages */
211
+ protected fAdapter: CacheStorageAdapter | null;
212
+ /**
213
+ * Returns the storage adapter this manager installed into the caching engine.
214
+ *
215
+ * @returns {CacheStorageAdapter | null} The adapter, or null before initialization
216
+ */
217
+ get adapter(): CacheStorageAdapter | null;
218
+ /**
219
+ * Returns the currently configured defaults.
220
+ *
221
+ * @returns {CacheTypes.Defaults} A copy of the active defaults
222
+ */
223
+ get defaults(): CacheTypes.Defaults;
224
+ /**
225
+ * Sets the application wide cache defaults. Values passed per cached function
226
+ * always win over these. Calling it repeatedly merges into the existing defaults.
227
+ *
228
+ * @param {CacheTypes.Defaults} defaults - Defaults to apply to every cached function
229
+ * @returns {void}
230
+ */
231
+ configure(defaults: CacheTypes.Defaults): void;
232
+ /**
233
+ * Wraps a function with caching.
234
+ *
235
+ * The returned function keeps the original signature and adds `resolveKeys()`,
236
+ * `invalidate()` and `expire()` helpers keyed exactly like the cached calls, so
237
+ * no cache key ever has to be rebuilt by hand.
238
+ *
239
+ * @template T - Return type of the wrapped function
240
+ * @template ArgsT - Argument tuple of the wrapped function
241
+ * @param {(...args: ArgsT) => T | Promise<T>} fn - The function to cache
242
+ * @param {CacheTypes.Options<T, ArgsT>} [options] - Per-function cache options
243
+ * @returns {CacheTypes.CachedFunction<T, ArgsT>} The cached function
244
+ */
245
+ cached<T, ArgsT extends unknown[] = any[]>(fn: (...args: ArgsT) => T | Promise<T>, options?: CacheTypes.Options<T, ArgsT>): CacheTypes.CachedFunction<T, ArgsT>;
246
+ /**
247
+ * Removes cached entries for the given arguments from every storage tier.
248
+ *
249
+ * Pass the same options (`name`, `group`, `storage`, `getKey`) the entry was
250
+ * cached with so the very same keys are resolved.
251
+ *
252
+ * @template ArgsT - Argument tuple the entry was cached under
253
+ * @param {CacheTypes.Options<any, ArgsT>} options - Options identifying the cached function
254
+ * @param {ArgsT} args - Arguments identifying the entry
255
+ * @returns {Promise<void>} Resolves once every tier has been cleared
256
+ */
257
+ invalidate<ArgsT extends unknown[] = any[]>(options: CacheTypes.Options<any, ArgsT>, ...args: ArgsT): Promise<void>;
258
+ /**
259
+ * Marks cached entries as stale without removing them.
260
+ *
261
+ * With `swr` enabled the stale value keeps being served (within `staleMaxAge`)
262
+ * while the next access refreshes it in the background; without it, the next
263
+ * call re-resolves before returning.
264
+ *
265
+ * @template ArgsT - Argument tuple the entry was cached under
266
+ * @param {CacheTypes.Options<any, ArgsT>} options - Options identifying the cached function
267
+ * @param {ArgsT} args - Arguments identifying the entry
268
+ * @returns {Promise<void>} Resolves once every tier has been marked
269
+ */
270
+ expire<ArgsT extends unknown[] = any[]>(options: CacheTypes.Options<any, ArgsT>, ...args: ArgsT): Promise<void>;
271
+ /**
272
+ * Resolves every storage key (one per storage tier) the given arguments cache under.
273
+ * Useful for debugging and for cache inspection tooling.
274
+ *
275
+ * @template ArgsT - Argument tuple the entry was cached under
276
+ * @param {CacheTypes.Options<any, ArgsT>} options - Options identifying the cached function
277
+ * @param {ArgsT} args - Arguments identifying the entry
278
+ * @returns {Promise<string[]>} The resolved storage keys
279
+ */
280
+ resolveKeys<ArgsT extends unknown[] = any[]>(options: CacheTypes.Options<any, ArgsT>, ...args: ArgsT): Promise<string[]>;
281
+ /**
282
+ * Merges the configured defaults into per-function options and translates the
283
+ * Vercube `storage` option into the key base the storage adapter routes on.
284
+ *
285
+ * @template T - Return type of the cached function
286
+ * @template ArgsT - Argument tuple of the cached function
287
+ * @param {CacheTypes.Options<T, ArgsT>} options - Per-function cache options
288
+ * @returns {CacheOptions<T, ArgsT>} Options understood by the caching engine
289
+ * @protected
290
+ */
291
+ protected resolveOptions<T, ArgsT extends unknown[]>(options: CacheTypes.Options<T, ArgsT>): CacheOptions<T, ArgsT>;
292
+ /**
293
+ * Drops keys whose value is `undefined`, so that an explicitly passed
294
+ * `undefined` never shadows a configured default with a missing value.
295
+ *
296
+ * @template T - Shape of the option object
297
+ * @param {T} options - Options to clean up
298
+ * @returns {Partial<T>} The options without undefined values
299
+ * @protected
300
+ */
301
+ protected stripUndefined<T extends object>(options: T): Partial<T>;
302
+ /**
303
+ * Installs the storage adapter into the caching engine.
304
+ * Called automatically with the `@Init()` decorator.
305
+ *
306
+ * @returns {void}
307
+ * @protected
308
+ */
309
+ protected init(): void;
310
+ }
311
+ //#endregion
312
+ //#region src/Decorators/Cache.d.ts
313
+ /**
314
+ * Decorator implementation that replaces the decorated method with a cached
315
+ * version of itself.
316
+ *
317
+ * The wrapper is built lazily on the first call so that storages mounted after
318
+ * the container has been flushed - and defaults configured during bootstrap -
319
+ * are still picked up.
320
+ */
321
+ declare class CacheDecorator extends BaseDecorator<CacheTypes.DecoratorOptions> {
322
+ /** Cache manager owning the caching engine wiring */
323
+ protected gCacheManager: CacheManager;
324
+ /** The untouched method, kept so it can be restored on destroy */
325
+ protected fOriginalMethod: ((...args: unknown[]) => unknown) | null;
326
+ /** Whether the method lived on the instance itself rather than on the prototype */
327
+ protected fOwnMethod: boolean;
328
+ /**
329
+ * Replaces the decorated method with its cached counterpart.
330
+ *
331
+ * @returns {void}
332
+ */
333
+ created(): void;
334
+ /**
335
+ * Drops the cached wrapper and puts the original method back in place.
336
+ *
337
+ * A method defined on the instance itself (a class field holding a function)
338
+ * is written back, while a prototype method is simply uncovered by removing
339
+ * the wrapper the decorator added to the instance.
340
+ *
341
+ * @returns {void}
342
+ */
343
+ destroyed(): void;
344
+ }
345
+ /**
346
+ * Caches the result of the decorated method.
347
+ *
348
+ * The cache key is derived from the method's arguments, so every distinct set of
349
+ * arguments gets its own entry. Concurrent calls for the same key are coalesced
350
+ * into a single execution, and entries are automatically dropped when the method
351
+ * body or its cache options change.
352
+ *
353
+ * The decorated method gains three helpers, keyed exactly like the cached calls:
354
+ * `resolveKeys(...args)`, `invalidate(...args)` and `expire(...args)`.
355
+ *
356
+ * @param {CacheTypes.DecoratorOptions} [options] - Cache options for this method
357
+ * @returns {Function} The method decorator
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * class UsersService {
362
+ * @Cache({ maxAge: 60, swr: true, staleMaxAge: 300, storage: 'redis' })
363
+ * public async getUser(id: string): Promise<User> {
364
+ * return this.database.findUser(id);
365
+ * }
366
+ * }
367
+ *
368
+ * await usersService.getUser('123');
369
+ * await (usersService.getUser as CacheTypes.CachedMethod<[string], User>).invalidate('123');
370
+ * ```
371
+ *
372
+ * @example
373
+ * ```ts
374
+ * // arguments that are not safely hashable (Request, streams, class instances)
375
+ * // should be projected into an explicit key
376
+ * class ReportsController {
377
+ * @Cache({ maxAge: 300, getKey: (range: DateRange) => `${range.from}-${range.to}` })
378
+ * public async report(range: DateRange): Promise<Report> {
379
+ * return this.reports.build(range);
380
+ * }
381
+ * }
382
+ * ```
383
+ */
384
+ declare function Cache(options?: CacheTypes.DecoratorOptions): Function;
385
+ //#endregion
386
+ //#region src/Errors/CacheError.d.ts
387
+ /**
388
+ * Custom error class for cache-related errors.
389
+ * Wraps underlying cache/storage errors with standardized error messages.
390
+ */
391
+ declare class CacheError extends Error {
392
+ /**
393
+ * The original error that caused this cache error
394
+ */
395
+ readonly cause?: Error;
396
+ /**
397
+ * The cache operation that failed
398
+ */
399
+ readonly operation: string;
400
+ /**
401
+ * Additional metadata about the error (non-sensitive)
402
+ */
403
+ readonly metadata?: Record<string, unknown>;
404
+ constructor(message: string, operation: string, cause?: Error, metadata?: Record<string, unknown>);
405
+ }
406
+ //#endregion
407
+ export { CACHE_BASE_PREFIX, Cache, CacheDecorator, CacheError, CacheManager, CacheStorageAdapter, CacheTypes, cacheBaseForStorage, storageNameFromCacheKey };
package/dist/index.mjs ADDED
@@ -0,0 +1,461 @@
1
+ import { BaseDecorator, Container, Init, Inject, InjectOptional, createDecorator } from "@vercube/di";
2
+ import { hash } from "ohash";
3
+ import { Logger } from "@vercube/logger";
4
+ import { StorageManager } from "@vercube/storage";
5
+ import { defineCachedFunction, expireCache, invalidateCache, resolveCacheKeys, setStorage, useStorage } from "ocache";
6
+ import { MemoryStorage } from "@vercube/storage/drivers/MemoryStorage";
7
+ //#region src/Errors/CacheError.ts
8
+ /**
9
+ * Custom error class for cache-related errors.
10
+ * Wraps underlying cache/storage errors with standardized error messages.
11
+ */
12
+ var CacheError = class CacheError extends Error {
13
+ /**
14
+ * The original error that caused this cache error
15
+ */
16
+ cause;
17
+ /**
18
+ * The cache operation that failed
19
+ */
20
+ operation;
21
+ /**
22
+ * Additional metadata about the error (non-sensitive)
23
+ */
24
+ metadata;
25
+ constructor(message, operation, cause, metadata) {
26
+ super(message);
27
+ this.name = "CacheError";
28
+ this.operation = operation;
29
+ this.cause = cause;
30
+ this.metadata = metadata;
31
+ if (Error.captureStackTrace) Error.captureStackTrace(this, CacheError);
32
+ }
33
+ };
34
+ //#endregion
35
+ //#region \0@oxc-project+runtime@0.140.0/helpers/esm/decorate.js
36
+ function __decorate(decorators, target, key, desc) {
37
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
38
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
39
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
40
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
41
+ }
42
+ //#endregion
43
+ //#region src/Services/CacheStorageAdapter.ts
44
+ /**
45
+ * Prefix every cache key starts with. The segment that follows it (when present)
46
+ * is the name of the storage mounted in {@link StorageManager} that backs the entry.
47
+ *
48
+ * ocache builds keys as `<base>:<group>:<name>:<key>.json`, so the base is always
49
+ * the first colon separated segment and can be parsed back out on read/write.
50
+ */
51
+ const CACHE_BASE_PREFIX = "/cache";
52
+ /**
53
+ * Builds the cache key base for a storage mounted in {@link StorageManager}.
54
+ *
55
+ * @param {string} [storage] - Name of the mounted storage, `default` when omitted
56
+ * @returns {string} The base prefix cache keys for that storage start with
57
+ */
58
+ function cacheBaseForStorage(storage) {
59
+ if (storage?.includes(":")) throw new CacheError("Storage name used for caching must not contain a colon", "cacheBaseForStorage", void 0, { storage });
60
+ return storage && storage !== "default" ? `${CACHE_BASE_PREFIX}/${storage}` : CACHE_BASE_PREFIX;
61
+ }
62
+ /**
63
+ * Extracts the mounted storage name back out of a full cache key.
64
+ *
65
+ * @param {string} key - A cache key produced by the caching engine
66
+ * @returns {string} Name of the storage the key belongs to, `default` when the key carries no storage segment
67
+ */
68
+ function storageNameFromCacheKey(key) {
69
+ const separatorIndex = key.indexOf(":");
70
+ const base = separatorIndex === -1 ? key : key.slice(0, separatorIndex);
71
+ if (!base.startsWith(`/cache/`)) return "default";
72
+ return base.slice(7) || "default";
73
+ }
74
+ /**
75
+ * Bridges the caching engine onto the Vercube {@link StorageManager}.
76
+ *
77
+ * The cache module owns no storage of its own - every entry lives in a storage
78
+ * mounted in `@vercube/storage`, so a cache can be backed by memory, S3 or any
79
+ * other driver simply by mounting it, and the very same entries are visible
80
+ * through the regular storage API.
81
+ *
82
+ * Every cache key carries the name of the storage it belongs to in its base
83
+ * segment, so a single adapter instance serves cached functions that live in
84
+ * different storages - and even ones that span several of them at once.
85
+ *
86
+ * A cached function may point at a storage that has not been mounted; in that
87
+ * case a {@link MemoryStorage} is mounted under that name on first use so that
88
+ * caching works with zero configuration. Mounting the name yourself - before or
89
+ * after - is what swaps the backend for a real one.
90
+ */
91
+ var CacheStorageAdapter = class {
92
+ /** Storage manager holding every mounted storage */
93
+ gStorageManager;
94
+ /** Logger instance */
95
+ gLogger;
96
+ /** In-flight auto-mounts, keyed by storage name, so concurrent calls share one storage */
97
+ fMounting = /* @__PURE__ */ new Map();
98
+ /**
99
+ * Reads a cache entry from the storage its key points at.
100
+ *
101
+ * @template T - Type of the stored entry
102
+ * @param {string} key - Full cache key, including the storage base prefix
103
+ * @returns {Promise<T | null>} The stored entry, or null when it is absent
104
+ */
105
+ async get(key) {
106
+ return await (await this.resolveStorage(key)).getItem(key) ?? null;
107
+ }
108
+ /**
109
+ * Writes a cache entry to the storage its key points at.
110
+ *
111
+ * The caching engine signals a deletion by writing `null`, which is mapped onto
112
+ * the storage's delete operation so that no empty entries are left behind.
113
+ *
114
+ * @template T - Type of the value to store
115
+ * @param {string} key - Full cache key, including the storage base prefix
116
+ * @param {T} value - Entry to store, or null to remove it
117
+ * @param {{ ttl?: number }} [opts] - Storage hints, `ttl` in seconds
118
+ * @returns {Promise<void>} Resolves once the write is complete
119
+ */
120
+ async set(key, value, opts) {
121
+ const storage = await this.resolveStorage(key);
122
+ if (value === null || value === void 0) {
123
+ await storage.deleteItem(key);
124
+ return;
125
+ }
126
+ await storage.setItem(key, value, opts);
127
+ }
128
+ /**
129
+ * Resolves the mounted storage a cache key belongs to, mounting an in-memory
130
+ * one under that name when nothing has been mounted yet.
131
+ *
132
+ * @param {string} key - Full cache key, including the storage base prefix
133
+ * @returns {Promise<Storage>} The storage backing that key
134
+ * @protected
135
+ */
136
+ async resolveStorage(key) {
137
+ const manager = this.gStorageManager;
138
+ if (!manager) throw new CacheError("StorageManager is not registered in the container - bind it so cached entries have a storage to live in", "resolveStorage", void 0, { key });
139
+ const name = storageNameFromCacheKey(key);
140
+ return manager.getStorage(name) ?? await this.mountFallback(manager, name);
141
+ }
142
+ /**
143
+ * Mounts an in-memory storage under the given name, so that a cached function
144
+ * pointing at a storage nobody mounted still works.
145
+ *
146
+ * @param {StorageManager} manager - Storage manager to mount into
147
+ * @param {string} name - Name of the storage to mount
148
+ * @returns {Promise<Storage>} The freshly mounted storage
149
+ * @protected
150
+ */
151
+ async mountFallback(manager, name) {
152
+ const pending = this.fMounting.get(name);
153
+ if (pending) return pending;
154
+ const mounting = (async () => {
155
+ await manager.mount({
156
+ name,
157
+ storage: MemoryStorage
158
+ });
159
+ const storage = manager.getStorage(name);
160
+ if (!storage) throw new CacheError("Unable to mount a fallback cache storage", "mountFallback", void 0, { storage: name });
161
+ await storage.initialize(void 0);
162
+ this.gLogger?.warn(`Vercube/CacheStorageAdapter::storage "${name}" was not mounted, an in-memory one has been mounted automatically`);
163
+ return storage;
164
+ })();
165
+ this.fMounting.set(name, mounting);
166
+ mounting.catch(() => void 0).finally(() => {
167
+ if (this.fMounting.get(name) === mounting) this.fMounting.delete(name);
168
+ });
169
+ return mounting;
170
+ }
171
+ };
172
+ __decorate([InjectOptional(StorageManager)], CacheStorageAdapter.prototype, "gStorageManager", void 0);
173
+ __decorate([InjectOptional(Logger)], CacheStorageAdapter.prototype, "gLogger", void 0);
174
+ //#endregion
175
+ //#region src/Services/CacheManager.ts
176
+ /**
177
+ * Central entry point of the cache module.
178
+ *
179
+ * The CacheManager owns the wiring between the caching engine and the storages
180
+ * mounted in `@vercube/storage`, holds the application wide defaults and exposes
181
+ * the imperative API used both directly and by the `@Cache()` decorator.
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * container.bind(CacheManager);
186
+ *
187
+ * const cache = container.get(CacheManager);
188
+ * cache.configure({ maxAge: 60, swr: true, staleMaxAge: 300, storage: 'cache' });
189
+ *
190
+ * const getUser = cache.cached((id: string) => db.users.find(id), { name: 'getUser' });
191
+ *
192
+ * await getUser('123'); // resolves and stores
193
+ * await getUser('123'); // served from cache
194
+ * await getUser.invalidate('123'); // drops the entry
195
+ * ```
196
+ */
197
+ var CacheManager = class {
198
+ /** Container instance */
199
+ gContainer;
200
+ /** Logger instance */
201
+ gLogger;
202
+ /** Application wide defaults applied to every cached function */
203
+ fDefaults = {};
204
+ /** Storage adapter bridging the caching engine onto the mounted storages */
205
+ fAdapter = null;
206
+ /**
207
+ * Returns the storage adapter this manager installed into the caching engine.
208
+ *
209
+ * @returns {CacheStorageAdapter | null} The adapter, or null before initialization
210
+ */
211
+ get adapter() {
212
+ return this.fAdapter;
213
+ }
214
+ /**
215
+ * Returns the currently configured defaults.
216
+ *
217
+ * @returns {CacheTypes.Defaults} A copy of the active defaults
218
+ */
219
+ get defaults() {
220
+ return { ...this.fDefaults };
221
+ }
222
+ /**
223
+ * Sets the application wide cache defaults. Values passed per cached function
224
+ * always win over these. Calling it repeatedly merges into the existing defaults.
225
+ *
226
+ * @param {CacheTypes.Defaults} defaults - Defaults to apply to every cached function
227
+ * @returns {void}
228
+ */
229
+ configure(defaults) {
230
+ this.fDefaults = {
231
+ ...this.fDefaults,
232
+ ...this.stripUndefined(defaults)
233
+ };
234
+ }
235
+ /**
236
+ * Wraps a function with caching.
237
+ *
238
+ * The returned function keeps the original signature and adds `resolveKeys()`,
239
+ * `invalidate()` and `expire()` helpers keyed exactly like the cached calls, so
240
+ * no cache key ever has to be rebuilt by hand.
241
+ *
242
+ * @template T - Return type of the wrapped function
243
+ * @template ArgsT - Argument tuple of the wrapped function
244
+ * @param {(...args: ArgsT) => T | Promise<T>} fn - The function to cache
245
+ * @param {CacheTypes.Options<T, ArgsT>} [options] - Per-function cache options
246
+ * @returns {CacheTypes.CachedFunction<T, ArgsT>} The cached function
247
+ */
248
+ cached(fn, options = {}) {
249
+ if (typeof fn !== "function") throw new CacheError("Cached target must be a function", "cached", void 0, { received: typeof fn });
250
+ return defineCachedFunction(fn, this.resolveOptions(options));
251
+ }
252
+ /**
253
+ * Removes cached entries for the given arguments from every storage tier.
254
+ *
255
+ * Pass the same options (`name`, `group`, `storage`, `getKey`) the entry was
256
+ * cached with so the very same keys are resolved.
257
+ *
258
+ * @template ArgsT - Argument tuple the entry was cached under
259
+ * @param {CacheTypes.Options<any, ArgsT>} options - Options identifying the cached function
260
+ * @param {ArgsT} args - Arguments identifying the entry
261
+ * @returns {Promise<void>} Resolves once every tier has been cleared
262
+ */
263
+ async invalidate(options, ...args) {
264
+ await invalidateCache({
265
+ options: this.resolveOptions(options),
266
+ args
267
+ });
268
+ }
269
+ /**
270
+ * Marks cached entries as stale without removing them.
271
+ *
272
+ * With `swr` enabled the stale value keeps being served (within `staleMaxAge`)
273
+ * while the next access refreshes it in the background; without it, the next
274
+ * call re-resolves before returning.
275
+ *
276
+ * @template ArgsT - Argument tuple the entry was cached under
277
+ * @param {CacheTypes.Options<any, ArgsT>} options - Options identifying the cached function
278
+ * @param {ArgsT} args - Arguments identifying the entry
279
+ * @returns {Promise<void>} Resolves once every tier has been marked
280
+ */
281
+ async expire(options, ...args) {
282
+ await expireCache({
283
+ options: this.resolveOptions(options),
284
+ args
285
+ });
286
+ }
287
+ /**
288
+ * Resolves every storage key (one per storage tier) the given arguments cache under.
289
+ * Useful for debugging and for cache inspection tooling.
290
+ *
291
+ * @template ArgsT - Argument tuple the entry was cached under
292
+ * @param {CacheTypes.Options<any, ArgsT>} options - Options identifying the cached function
293
+ * @param {ArgsT} args - Arguments identifying the entry
294
+ * @returns {Promise<string[]>} The resolved storage keys
295
+ */
296
+ async resolveKeys(options, ...args) {
297
+ return resolveCacheKeys({
298
+ options: this.resolveOptions(options),
299
+ args
300
+ });
301
+ }
302
+ /**
303
+ * Merges the configured defaults into per-function options and translates the
304
+ * Vercube `storage` option into the key base the storage adapter routes on.
305
+ *
306
+ * @template T - Return type of the cached function
307
+ * @template ArgsT - Argument tuple of the cached function
308
+ * @param {CacheTypes.Options<T, ArgsT>} options - Per-function cache options
309
+ * @returns {CacheOptions<T, ArgsT>} Options understood by the caching engine
310
+ * @protected
311
+ */
312
+ resolveOptions(options) {
313
+ const { storage, ...rest } = {
314
+ ...this.fDefaults,
315
+ ...this.stripUndefined(options)
316
+ };
317
+ const storages = Array.isArray(storage) ? storage : [storage];
318
+ if (storages.length === 0) throw new CacheError("At least one storage must be given when caching", "resolveOptions", void 0, { name: rest.name });
319
+ const base = storages.map((name) => cacheBaseForStorage(name));
320
+ return {
321
+ ...rest,
322
+ base: base.length === 1 ? base[0] : base
323
+ };
324
+ }
325
+ /**
326
+ * Drops keys whose value is `undefined`, so that an explicitly passed
327
+ * `undefined` never shadows a configured default with a missing value.
328
+ *
329
+ * @template T - Shape of the option object
330
+ * @param {T} options - Options to clean up
331
+ * @returns {Partial<T>} The options without undefined values
332
+ * @protected
333
+ */
334
+ stripUndefined(options) {
335
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== void 0));
336
+ }
337
+ /**
338
+ * Installs the storage adapter into the caching engine.
339
+ * Called automatically with the `@Init()` decorator.
340
+ *
341
+ * @returns {void}
342
+ * @protected
343
+ */
344
+ init() {
345
+ if (!this.gContainer.getOptional(StorageManager)) this.gLogger?.warn("Vercube/CacheManager::StorageManager is not registered - bind it so cached entries have a storage to live in");
346
+ if (useStorage() instanceof CacheStorageAdapter) this.gLogger?.warn("Vercube/CacheManager::another CacheManager is already installed in this process - cached functions from every container will now route through this one");
347
+ this.fAdapter = this.gContainer.resolve(CacheStorageAdapter);
348
+ setStorage(this.fAdapter);
349
+ }
350
+ };
351
+ __decorate([Inject(Container)], CacheManager.prototype, "gContainer", void 0);
352
+ __decorate([InjectOptional(Logger)], CacheManager.prototype, "gLogger", void 0);
353
+ __decorate([Init()], CacheManager.prototype, "init", null);
354
+ //#endregion
355
+ //#region src/Decorators/Cache.ts
356
+ /**
357
+ * Decorator implementation that replaces the decorated method with a cached
358
+ * version of itself.
359
+ *
360
+ * The wrapper is built lazily on the first call so that storages mounted after
361
+ * the container has been flushed - and defaults configured during bootstrap -
362
+ * are still picked up.
363
+ */
364
+ var CacheDecorator = class extends BaseDecorator {
365
+ /** Cache manager owning the caching engine wiring */
366
+ gCacheManager;
367
+ /** The untouched method, kept so it can be restored on destroy */
368
+ fOriginalMethod = null;
369
+ /** Whether the method lived on the instance itself rather than on the prototype */
370
+ fOwnMethod = false;
371
+ /**
372
+ * Replaces the decorated method with its cached counterpart.
373
+ *
374
+ * @returns {void}
375
+ */
376
+ created() {
377
+ const original = this.instance[this.propertyName];
378
+ if (typeof original !== "function") throw new CacheError("@Cache() can only be applied to methods", "decorate", void 0, {
379
+ property: this.propertyName,
380
+ received: typeof original
381
+ });
382
+ this.fOriginalMethod = original;
383
+ this.fOwnMethod = Object.hasOwn(this.instance, this.propertyName);
384
+ const options = {
385
+ name: `${this.instance?.constructor?.name ?? "anonymous"}.${this.propertyName}`,
386
+ integrity: hash([original, this.options ?? {}]),
387
+ ...this.options
388
+ };
389
+ let cached = null;
390
+ const resolveCached = () => {
391
+ cached ??= this.gCacheManager.cached((...args) => original.apply(this.instance, args), options);
392
+ return cached;
393
+ };
394
+ const wrapper = (...args) => resolveCached()(...args);
395
+ wrapper.resolveKeys = (...args) => resolveCached().resolveKeys(...args);
396
+ wrapper.invalidate = (...args) => resolveCached().invalidate(...args);
397
+ wrapper.expire = (...args) => resolveCached().expire(...args);
398
+ this.instance[this.propertyName] = wrapper;
399
+ }
400
+ /**
401
+ * Drops the cached wrapper and puts the original method back in place.
402
+ *
403
+ * A method defined on the instance itself (a class field holding a function)
404
+ * is written back, while a prototype method is simply uncovered by removing
405
+ * the wrapper the decorator added to the instance.
406
+ *
407
+ * @returns {void}
408
+ */
409
+ destroyed() {
410
+ if (!this.fOriginalMethod) return;
411
+ if (this.fOwnMethod) this.instance[this.propertyName] = this.fOriginalMethod;
412
+ else delete this.instance[this.propertyName];
413
+ this.fOriginalMethod = null;
414
+ this.fOwnMethod = false;
415
+ }
416
+ };
417
+ __decorate([Inject(CacheManager)], CacheDecorator.prototype, "gCacheManager", void 0);
418
+ /**
419
+ * Caches the result of the decorated method.
420
+ *
421
+ * The cache key is derived from the method's arguments, so every distinct set of
422
+ * arguments gets its own entry. Concurrent calls for the same key are coalesced
423
+ * into a single execution, and entries are automatically dropped when the method
424
+ * body or its cache options change.
425
+ *
426
+ * The decorated method gains three helpers, keyed exactly like the cached calls:
427
+ * `resolveKeys(...args)`, `invalidate(...args)` and `expire(...args)`.
428
+ *
429
+ * @param {CacheTypes.DecoratorOptions} [options] - Cache options for this method
430
+ * @returns {Function} The method decorator
431
+ *
432
+ * @example
433
+ * ```ts
434
+ * class UsersService {
435
+ * @Cache({ maxAge: 60, swr: true, staleMaxAge: 300, storage: 'redis' })
436
+ * public async getUser(id: string): Promise<User> {
437
+ * return this.database.findUser(id);
438
+ * }
439
+ * }
440
+ *
441
+ * await usersService.getUser('123');
442
+ * await (usersService.getUser as CacheTypes.CachedMethod<[string], User>).invalidate('123');
443
+ * ```
444
+ *
445
+ * @example
446
+ * ```ts
447
+ * // arguments that are not safely hashable (Request, streams, class instances)
448
+ * // should be projected into an explicit key
449
+ * class ReportsController {
450
+ * @Cache({ maxAge: 300, getKey: (range: DateRange) => `${range.from}-${range.to}` })
451
+ * public async report(range: DateRange): Promise<Report> {
452
+ * return this.reports.build(range);
453
+ * }
454
+ * }
455
+ * ```
456
+ */
457
+ function Cache(options = {}) {
458
+ return createDecorator(CacheDecorator, options);
459
+ }
460
+ //#endregion
461
+ export { CACHE_BASE_PREFIX, Cache, CacheDecorator, CacheError, CacheManager, CacheStorageAdapter, cacheBaseForStorage, storageNameFromCacheKey };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@vercube/cache",
3
+ "version": "1.2.0",
4
+ "description": "Cache module for Vercube framework",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/vercube/vercube.git",
8
+ "directory": "packages/cache"
9
+ },
10
+ "license": "MIT",
11
+ "sideEffects": false,
12
+ "type": "module",
13
+ "main": "./dist/index.mjs",
14
+ "module": "./dist/index.mjs",
15
+ "exports": {
16
+ ".": "./dist/index.mjs",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "types": "./dist/index.d.mts",
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "devDependencies": {
25
+ "@vercube/core": "1.2.0"
26
+ },
27
+ "dependencies": {
28
+ "ocache": "0.2.0",
29
+ "ohash": "2.0.12",
30
+ "@vercube/di": "1.2.0",
31
+ "@vercube/storage": "1.2.0",
32
+ "@vercube/logger": "1.2.0"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "tsdown --config ../../tsdown.config.ts --config-loader=unrun"
39
+ }
40
+ }