@nodellmcache/core 0.1.0 → 1.1.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/dist/index.cjs +85 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.mjs +85 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +23 -12
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @nodellmcache/core
|
|
2
|
+
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add stale-while-revalidate to `BaseCacheManager` via `getOrRevalidate(input, generator, { ttl, staleTtl })`: a stale-but-unexpired entry is served immediately while a fresh value is fetched in the background, with concurrent stale hits coalesced into a single refresh. Adds `staleAt` to `CacheMetadata` and `staleTtl` to `CacheOptions`.
|
|
8
|
+
- Add request coalescing (singleflight): concurrent misses for the same key run the generator once instead of N times. Applies to both `getOrGenerate` and `getOrRevalidate`. Inherited by every cache manager.
|
|
9
|
+
|
|
10
|
+
## 1.0.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- a2633d8: Initial release of `@nodellmcache/core`: shared interfaces and types (`StorageAdapter`, `VectorStoreAdapter`, `CompressionEngine`, `CacheEntry`, `MetricsSink`, `CacheType`, `LLMProvider`, `CompressionAlgo`), the `KeyBuilder`, `TTLManager`, `JsonSerializer`, the `BaseCacheManager` cache-aside base class, and the typed error hierarchy. Zero external dependencies.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NodeLLMCache 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/dist/index.cjs
CHANGED
|
@@ -143,6 +143,10 @@ var BaseCacheManager = class {
|
|
|
143
143
|
metrics;
|
|
144
144
|
hits = 0;
|
|
145
145
|
misses = 0;
|
|
146
|
+
/** Keys with an in-flight background revalidation, to avoid duplicate refreshes. */
|
|
147
|
+
refreshing = /* @__PURE__ */ new Set();
|
|
148
|
+
/** In-flight generations, so concurrent misses for the same key run the generator once. */
|
|
149
|
+
inflight = /* @__PURE__ */ new Map();
|
|
146
150
|
constructor(options) {
|
|
147
151
|
this.adapter = options.adapter;
|
|
148
152
|
this.defaultTTL = options.defaultTTL;
|
|
@@ -166,6 +170,7 @@ var BaseCacheManager = class {
|
|
|
166
170
|
buildEntry(key, value, options) {
|
|
167
171
|
const createdAt = Date.now();
|
|
168
172
|
const ttl = options?.ttl ?? this.defaultTTL;
|
|
173
|
+
const staleTtl = options?.staleTtl;
|
|
169
174
|
return {
|
|
170
175
|
key,
|
|
171
176
|
value,
|
|
@@ -177,7 +182,8 @@ var BaseCacheManager = class {
|
|
|
177
182
|
cacheType: this.cacheType,
|
|
178
183
|
provider: options?.provider,
|
|
179
184
|
model: options?.model,
|
|
180
|
-
tokenCount: options?.tokenCount
|
|
185
|
+
tokenCount: options?.tokenCount,
|
|
186
|
+
staleAt: staleTtl !== void 0 && staleTtl > 0 ? createdAt + staleTtl : void 0
|
|
181
187
|
}
|
|
182
188
|
};
|
|
183
189
|
}
|
|
@@ -211,17 +217,46 @@ var BaseCacheManager = class {
|
|
|
211
217
|
provider: options?.provider,
|
|
212
218
|
model: options?.model
|
|
213
219
|
});
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
return this.coalescedGenerate(key, generator, options, start);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Stale-while-revalidate read-through. Like {@link getOrGenerate}, but when a
|
|
224
|
+
* cached entry is *stale* (past its `staleTtl`/`staleAt` but not yet expired)
|
|
225
|
+
* it is returned immediately and a fresh value is fetched in the background.
|
|
226
|
+
* Concurrent stale hits for the same key trigger at most one background
|
|
227
|
+
* refresh. A failed background refresh is swallowed and the stale value stands
|
|
228
|
+
* until it fully expires.
|
|
229
|
+
*/
|
|
230
|
+
async getOrRevalidate(input, generator, options) {
|
|
231
|
+
if (options?.cache === false) {
|
|
232
|
+
return generator();
|
|
233
|
+
}
|
|
234
|
+
const key = this.buildKey(input, options);
|
|
235
|
+
const start = Date.now();
|
|
236
|
+
const cached = await this.adapter.get(key);
|
|
237
|
+
if (cached && !TTLManager.isExpired(cached)) {
|
|
238
|
+
this.hits++;
|
|
239
|
+
this.metrics.emit("cache.hit", {
|
|
240
|
+
cacheType: this.cacheType,
|
|
241
|
+
latencyMs: Date.now() - start,
|
|
242
|
+
tokensSaved: cached.metadata.tokenCount,
|
|
243
|
+
provider: options?.provider,
|
|
244
|
+
model: options?.model
|
|
245
|
+
});
|
|
246
|
+
const staleAt = cached.metadata.staleAt;
|
|
247
|
+
if (staleAt !== void 0 && staleAt <= Date.now()) {
|
|
248
|
+
this.revalidate(key, generator, options);
|
|
249
|
+
}
|
|
250
|
+
return cached.value;
|
|
251
|
+
}
|
|
252
|
+
this.misses++;
|
|
253
|
+
this.metrics.emit("cache.miss", {
|
|
219
254
|
cacheType: this.cacheType,
|
|
220
255
|
latencyMs: Date.now() - start,
|
|
221
256
|
provider: options?.provider,
|
|
222
257
|
model: options?.model
|
|
223
258
|
});
|
|
224
|
-
return
|
|
259
|
+
return this.coalescedGenerate(key, generator, options, start);
|
|
225
260
|
}
|
|
226
261
|
/**
|
|
227
262
|
* Removes a single entry by its input (and namespacing options).
|
|
@@ -242,6 +277,49 @@ var BaseCacheManager = class {
|
|
|
242
277
|
entryCount: adapterStats.entryCount
|
|
243
278
|
};
|
|
244
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Runs the generator and persists the result, deduplicating concurrent calls
|
|
282
|
+
* for the same key (singleflight): while one generation is in flight, other
|
|
283
|
+
* callers join it instead of invoking the generator again. Prevents a
|
|
284
|
+
* thundering herd of identical API calls on a cold key.
|
|
285
|
+
*/
|
|
286
|
+
coalescedGenerate(key, generator, options, start) {
|
|
287
|
+
const existing = this.inflight.get(key);
|
|
288
|
+
if (existing) return existing;
|
|
289
|
+
const promise = (async () => {
|
|
290
|
+
const value = await generator();
|
|
291
|
+
await this.persist(key, value, options, start);
|
|
292
|
+
return value;
|
|
293
|
+
})();
|
|
294
|
+
this.inflight.set(key, promise);
|
|
295
|
+
return promise.finally(() => this.inflight.delete(key));
|
|
296
|
+
}
|
|
297
|
+
/** Builds an entry, writes it through the adapter, and emits `cache.set`. */
|
|
298
|
+
async persist(key, value, options, start) {
|
|
299
|
+
const ttl = options?.ttl ?? this.defaultTTL;
|
|
300
|
+
const entry = this.buildEntry(key, value, options);
|
|
301
|
+
await this.adapter.set(key, entry, ttl);
|
|
302
|
+
this.metrics.emit("cache.set", {
|
|
303
|
+
cacheType: this.cacheType,
|
|
304
|
+
latencyMs: Date.now() - start,
|
|
305
|
+
provider: options?.provider,
|
|
306
|
+
model: options?.model
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/** Fire-and-forget background refresh for a stale entry, coalesced per key. */
|
|
310
|
+
revalidate(key, generator, options) {
|
|
311
|
+
if (this.refreshing.has(key)) return;
|
|
312
|
+
this.refreshing.add(key);
|
|
313
|
+
void (async () => {
|
|
314
|
+
try {
|
|
315
|
+
const value = await generator();
|
|
316
|
+
await this.persist(key, value, options, Date.now());
|
|
317
|
+
} catch {
|
|
318
|
+
} finally {
|
|
319
|
+
this.refreshing.delete(key);
|
|
320
|
+
}
|
|
321
|
+
})();
|
|
322
|
+
}
|
|
245
323
|
};
|
|
246
324
|
// Annotate the CommonJS export names for ESM import in node:
|
|
247
325
|
0 && (module.exports = {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/KeyBuilder.ts","../src/TTLManager.ts","../src/Serializer.ts","../src/BaseCacheManager.ts"],"sourcesContent":["export * from './types.js'\nexport * from './interfaces.js'\nexport * from './errors.js'\nexport { KeyBuilder } from './KeyBuilder.js'\nexport { TTLManager } from './TTLManager.js'\nexport { type Serializer, JsonSerializer } from './Serializer.js'\nexport {\n BaseCacheManager,\n noopMetrics,\n type BaseCacheManagerOptions,\n} from './BaseCacheManager.js'\n","/**\n * Base class for every error thrown by NodeLLMCache packages. Catching this\n * catches anything the library throws intentionally.\n */\nexport class NodeLLMCacheError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options)\n this.name = new.target.name\n // Restore prototype chain for instanceof across the ES5 transpile boundary.\n Object.setPrototypeOf(this, new.target.prototype)\n }\n}\n\n/** Thrown when a storage adapter operation fails. */\nexport class CacheAdapterError extends NodeLLMCacheError {}\n\n/** Thrown when compression or decompression fails. */\nexport class CompressionError extends NodeLLMCacheError {}\n\n/** Thrown when serialization or deserialization fails. */\nexport class SerializationError extends NodeLLMCacheError {}\n\n/** Thrown when a value fails validation (e.g. malformed configuration). */\nexport class ValidationError extends NodeLLMCacheError {}\n","import { createHash } from 'node:crypto'\nimport type { CacheType, LLMProvider } from './types.js'\n\n/**\n * Builds deterministic, collision-resistant cache keys.\n *\n * Format: `{type}:{provider}:{model}:{sha256-hash}`\n *\n * The raw input text is normalized and hashed with SHA-256 — keys never\n * contain raw prompt text, which keeps sensitive content out of storage keys\n * and logs.\n */\nexport class KeyBuilder {\n /**\n * Normalizes text before hashing so trivially different inputs collapse to\n * the same key: trims surrounding whitespace, lowercases, and collapses any\n * run of whitespace to a single space.\n */\n static normalize(text: string): string {\n return text.trim().toLowerCase().replace(/\\s+/g, ' ')\n }\n\n /**\n * Produces the SHA-256 hex digest of the normalized text.\n */\n static hash(text: string): string {\n return createHash('sha256').update(KeyBuilder.normalize(text)).digest('hex')\n }\n\n /**\n * Builds a fully namespaced cache key.\n *\n * @example\n * KeyBuilder.build('prompt', 'openai', 'gpt-4o', 'hello world')\n * // 'prompt:openai:gpt-4o:b94d27b9...'\n */\n static build(\n type: CacheType,\n provider: LLMProvider | string,\n model: string,\n text: string,\n ): string {\n return `${type}:${provider}:${model}:${KeyBuilder.hash(text)}`\n }\n}\n","import type { CacheEntry } from './interfaces.js'\n\n/**\n * Centralizes time-to-live arithmetic: computing expiry timestamps, checking\n * expiry, and supporting sliding-window refresh. All durations are relative\n * milliseconds; all timestamps are absolute epoch milliseconds.\n */\nexport class TTLManager {\n /**\n * Computes the absolute expiry timestamp for an entry created at `createdAt`\n * with a relative `ttl`. Returns `undefined` when `ttl` is not a positive\n * number, meaning the entry never expires.\n */\n static computeExpiresAt(createdAt: number, ttl?: number): number | undefined {\n if (ttl === undefined || ttl <= 0) return undefined\n return createdAt + ttl\n }\n\n /**\n * Returns true when the entry has an expiry and that expiry is at or before\n * `now` (defaults to the current time).\n */\n static isExpired(entry: Pick<CacheEntry<unknown>, 'expiresAt'>, now: number = Date.now()): boolean {\n return entry.expiresAt !== undefined && entry.expiresAt <= now\n }\n\n /**\n * Milliseconds remaining until the entry expires. Returns `Infinity` for\n * entries with no expiry, and `0` for already-expired entries.\n */\n static remaining(entry: Pick<CacheEntry<unknown>, 'expiresAt'>, now: number = Date.now()): number {\n if (entry.expiresAt === undefined) return Infinity\n return Math.max(0, entry.expiresAt - now)\n }\n\n /**\n * Computes a refreshed expiry for a sliding-window TTL: extends the entry's\n * life by `ttl` from `now`. Returns `undefined` when `ttl` is not positive.\n */\n static slide(ttl?: number, now: number = Date.now()): number | undefined {\n return TTLManager.computeExpiresAt(now, ttl)\n }\n}\n","import { SerializationError } from './errors.js'\n\n/**\n * Converts values to and from `Buffer` for storage. Implementations decide the\n * wire format.\n *\n * The architecture calls for MessagePack as the primary format, but\n * `@nodellmcache/core` must stay dependency-free, so it ships only the JSON\n * implementation below. A MessagePack serializer can be supplied by an optional\n * package and injected wherever a `Serializer` is accepted.\n */\nexport interface Serializer {\n serialize<T>(value: T): Buffer\n deserialize<T>(data: Buffer): T\n}\n\n/**\n * Default JSON-backed serializer. Zero dependencies; handles the common case\n * and wraps failures in {@link SerializationError}.\n */\nexport class JsonSerializer implements Serializer {\n serialize<T>(value: T): Buffer {\n try {\n return Buffer.from(JSON.stringify(value), 'utf8')\n } catch (cause) {\n throw new SerializationError('Failed to serialize value to JSON', { cause })\n }\n }\n\n deserialize<T>(data: Buffer): T {\n try {\n return JSON.parse(data.toString('utf8')) as T\n } catch (cause) {\n throw new SerializationError('Failed to deserialize JSON value', { cause })\n }\n }\n}\n","import { KeyBuilder } from './KeyBuilder.js'\nimport { TTLManager } from './TTLManager.js'\nimport type {\n CacheEntry,\n CacheOptions,\n CacheStats,\n MetricsSink,\n StorageAdapter,\n} from './interfaces.js'\nimport type { CacheType } from './types.js'\n\n/** A metrics sink that discards everything; the default when none is injected. */\nexport const noopMetrics: MetricsSink = {\n emit() {\n // intentionally empty\n },\n}\n\n/**\n * Construction options shared by all cache managers.\n */\nexport interface BaseCacheManagerOptions<T> {\n adapter: StorageAdapter<T>\n /** Default relative TTL in milliseconds applied to entries lacking their own. */\n defaultTTL?: number\n /** Metrics sink; defaults to a no-op so core stays dependency-free. */\n metrics?: MetricsSink\n}\n\n/**\n * Shared base for every feature cache (prompt, embedding, semantic, ...).\n *\n * Provides the cache-aside `getOrGenerate` flow, key building, entry\n * construction, invalidation, and hit/miss accounting. Subclasses declare their\n * {@link CacheType} and may override {@link buildKey} for custom namespacing.\n */\nexport abstract class BaseCacheManager<T> {\n /** The workload category for keys and metrics. */\n protected abstract readonly cacheType: CacheType\n\n protected readonly adapter: StorageAdapter<T>\n protected readonly defaultTTL: number | undefined\n protected readonly metrics: MetricsSink\n\n private hits = 0\n private misses = 0\n\n constructor(options: BaseCacheManagerOptions<T>) {\n this.adapter = options.adapter\n this.defaultTTL = options.defaultTTL\n this.metrics = options.metrics ?? noopMetrics\n }\n\n /**\n * Builds the storage key for an input. Defaults to the canonical\n * `{type}:{provider}:{model}:{hash}` format via {@link KeyBuilder}.\n */\n protected buildKey(input: string, options?: CacheOptions): string {\n return KeyBuilder.build(\n this.cacheType,\n options?.provider ?? 'unknown',\n options?.model ?? 'default',\n input,\n )\n }\n\n /**\n * Wraps a value in a {@link CacheEntry} with computed expiry and metadata.\n */\n protected buildEntry(key: string, value: T, options?: CacheOptions): CacheEntry<T> {\n const createdAt = Date.now()\n const ttl = options?.ttl ?? this.defaultTTL\n return {\n key,\n value,\n createdAt,\n expiresAt: TTLManager.computeExpiresAt(createdAt, ttl),\n metadata: {\n compressed: false,\n originalSize: 0,\n cacheType: this.cacheType,\n provider: options?.provider,\n model: options?.model,\n tokenCount: options?.tokenCount,\n },\n }\n }\n\n /**\n * Cache-aside read-through. Returns the cached value on a hit, otherwise\n * invokes `generator`, stores the result, and returns it. Set\n * `options.cache = false` to bypass the cache for both read and write.\n */\n async getOrGenerate(\n input: string,\n generator: () => Promise<T>,\n options?: CacheOptions,\n ): Promise<T> {\n // A deliberate bypass is neither a hit nor a miss — skip the cache and\n // metrics entirely so accounting reflects only real cache consultations.\n if (options?.cache === false) {\n return generator()\n }\n\n const key = this.buildKey(input, options)\n const start = Date.now()\n\n const cached = await this.adapter.get(key)\n if (cached && !TTLManager.isExpired(cached)) {\n this.hits++\n this.metrics.emit('cache.hit', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n tokensSaved: cached.metadata.tokenCount,\n provider: options?.provider,\n model: options?.model,\n })\n return cached.value\n }\n\n this.misses++\n this.metrics.emit('cache.miss', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n\n const value = await generator()\n\n const ttl = options?.ttl ?? this.defaultTTL\n const entry = this.buildEntry(key, value, options)\n await this.adapter.set(key, entry, ttl)\n this.metrics.emit('cache.set', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n\n return value\n }\n\n /**\n * Removes a single entry by its input (and namespacing options).\n */\n async invalidate(input: string, options?: CacheOptions): Promise<void> {\n await this.adapter.delete(this.buildKey(input, options))\n }\n\n /**\n * Returns hit/miss accounting for this manager plus the adapter's entry count.\n */\n async stats(): Promise<CacheStats> {\n const total = this.hits + this.misses\n const adapterStats = await this.adapter.stats()\n return {\n hits: this.hits,\n misses: this.misses,\n hitRate: total === 0 ? 0 : this.hits / total,\n entryCount: adapterStats.entryCount,\n }\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/KeyBuilder.ts","../src/TTLManager.ts","../src/Serializer.ts","../src/BaseCacheManager.ts"],"sourcesContent":["export * from './types.js'\nexport * from './interfaces.js'\nexport * from './errors.js'\nexport { KeyBuilder } from './KeyBuilder.js'\nexport { TTLManager } from './TTLManager.js'\nexport { type Serializer, JsonSerializer } from './Serializer.js'\nexport {\n BaseCacheManager,\n noopMetrics,\n type BaseCacheManagerOptions,\n} from './BaseCacheManager.js'\n","/**\n * Base class for every error thrown by NodeLLMCache packages. Catching this\n * catches anything the library throws intentionally.\n */\nexport class NodeLLMCacheError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options)\n this.name = new.target.name\n // Restore prototype chain for instanceof across the ES5 transpile boundary.\n Object.setPrototypeOf(this, new.target.prototype)\n }\n}\n\n/** Thrown when a storage adapter operation fails. */\nexport class CacheAdapterError extends NodeLLMCacheError {}\n\n/** Thrown when compression or decompression fails. */\nexport class CompressionError extends NodeLLMCacheError {}\n\n/** Thrown when serialization or deserialization fails. */\nexport class SerializationError extends NodeLLMCacheError {}\n\n/** Thrown when a value fails validation (e.g. malformed configuration). */\nexport class ValidationError extends NodeLLMCacheError {}\n","import { createHash } from 'node:crypto'\nimport type { CacheType, LLMProvider } from './types.js'\n\n/**\n * Builds deterministic, collision-resistant cache keys.\n *\n * Format: `{type}:{provider}:{model}:{sha256-hash}`\n *\n * The raw input text is normalized and hashed with SHA-256 — keys never\n * contain raw prompt text, which keeps sensitive content out of storage keys\n * and logs.\n */\nexport class KeyBuilder {\n /**\n * Normalizes text before hashing so trivially different inputs collapse to\n * the same key: trims surrounding whitespace, lowercases, and collapses any\n * run of whitespace to a single space.\n */\n static normalize(text: string): string {\n return text.trim().toLowerCase().replace(/\\s+/g, ' ')\n }\n\n /**\n * Produces the SHA-256 hex digest of the normalized text.\n */\n static hash(text: string): string {\n return createHash('sha256').update(KeyBuilder.normalize(text)).digest('hex')\n }\n\n /**\n * Builds a fully namespaced cache key.\n *\n * @example\n * KeyBuilder.build('prompt', 'openai', 'gpt-4o', 'hello world')\n * // 'prompt:openai:gpt-4o:b94d27b9...'\n */\n static build(\n type: CacheType,\n provider: LLMProvider | string,\n model: string,\n text: string,\n ): string {\n return `${type}:${provider}:${model}:${KeyBuilder.hash(text)}`\n }\n}\n","import type { CacheEntry } from './interfaces.js'\n\n/**\n * Centralizes time-to-live arithmetic: computing expiry timestamps, checking\n * expiry, and supporting sliding-window refresh. All durations are relative\n * milliseconds; all timestamps are absolute epoch milliseconds.\n */\nexport class TTLManager {\n /**\n * Computes the absolute expiry timestamp for an entry created at `createdAt`\n * with a relative `ttl`. Returns `undefined` when `ttl` is not a positive\n * number, meaning the entry never expires.\n */\n static computeExpiresAt(createdAt: number, ttl?: number): number | undefined {\n if (ttl === undefined || ttl <= 0) return undefined\n return createdAt + ttl\n }\n\n /**\n * Returns true when the entry has an expiry and that expiry is at or before\n * `now` (defaults to the current time).\n */\n static isExpired(entry: Pick<CacheEntry<unknown>, 'expiresAt'>, now: number = Date.now()): boolean {\n return entry.expiresAt !== undefined && entry.expiresAt <= now\n }\n\n /**\n * Milliseconds remaining until the entry expires. Returns `Infinity` for\n * entries with no expiry, and `0` for already-expired entries.\n */\n static remaining(entry: Pick<CacheEntry<unknown>, 'expiresAt'>, now: number = Date.now()): number {\n if (entry.expiresAt === undefined) return Infinity\n return Math.max(0, entry.expiresAt - now)\n }\n\n /**\n * Computes a refreshed expiry for a sliding-window TTL: extends the entry's\n * life by `ttl` from `now`. Returns `undefined` when `ttl` is not positive.\n */\n static slide(ttl?: number, now: number = Date.now()): number | undefined {\n return TTLManager.computeExpiresAt(now, ttl)\n }\n}\n","import { SerializationError } from './errors.js'\n\n/**\n * Converts values to and from `Buffer` for storage. Implementations decide the\n * wire format.\n *\n * The architecture calls for MessagePack as the primary format, but\n * `@nodellmcache/core` must stay dependency-free, so it ships only the JSON\n * implementation below. A MessagePack serializer can be supplied by an optional\n * package and injected wherever a `Serializer` is accepted.\n */\nexport interface Serializer {\n serialize<T>(value: T): Buffer\n deserialize<T>(data: Buffer): T\n}\n\n/**\n * Default JSON-backed serializer. Zero dependencies; handles the common case\n * and wraps failures in {@link SerializationError}.\n */\nexport class JsonSerializer implements Serializer {\n serialize<T>(value: T): Buffer {\n try {\n return Buffer.from(JSON.stringify(value), 'utf8')\n } catch (cause) {\n throw new SerializationError('Failed to serialize value to JSON', { cause })\n }\n }\n\n deserialize<T>(data: Buffer): T {\n try {\n return JSON.parse(data.toString('utf8')) as T\n } catch (cause) {\n throw new SerializationError('Failed to deserialize JSON value', { cause })\n }\n }\n}\n","import { KeyBuilder } from './KeyBuilder.js'\nimport { TTLManager } from './TTLManager.js'\nimport type {\n CacheEntry,\n CacheOptions,\n CacheStats,\n MetricsSink,\n StorageAdapter,\n} from './interfaces.js'\nimport type { CacheType } from './types.js'\n\n/** A metrics sink that discards everything; the default when none is injected. */\nexport const noopMetrics: MetricsSink = {\n emit() {\n // intentionally empty\n },\n}\n\n/**\n * Construction options shared by all cache managers.\n */\nexport interface BaseCacheManagerOptions<T> {\n adapter: StorageAdapter<T>\n /** Default relative TTL in milliseconds applied to entries lacking their own. */\n defaultTTL?: number\n /** Metrics sink; defaults to a no-op so core stays dependency-free. */\n metrics?: MetricsSink\n}\n\n/**\n * Shared base for every feature cache (prompt, embedding, semantic, ...).\n *\n * Provides the cache-aside `getOrGenerate` flow, key building, entry\n * construction, invalidation, and hit/miss accounting. Subclasses declare their\n * {@link CacheType} and may override {@link buildKey} for custom namespacing.\n */\nexport abstract class BaseCacheManager<T> {\n /** The workload category for keys and metrics. */\n protected abstract readonly cacheType: CacheType\n\n protected readonly adapter: StorageAdapter<T>\n protected readonly defaultTTL: number | undefined\n protected readonly metrics: MetricsSink\n\n private hits = 0\n private misses = 0\n /** Keys with an in-flight background revalidation, to avoid duplicate refreshes. */\n private readonly refreshing = new Set<string>()\n /** In-flight generations, so concurrent misses for the same key run the generator once. */\n private readonly inflight = new Map<string, Promise<T>>()\n\n constructor(options: BaseCacheManagerOptions<T>) {\n this.adapter = options.adapter\n this.defaultTTL = options.defaultTTL\n this.metrics = options.metrics ?? noopMetrics\n }\n\n /**\n * Builds the storage key for an input. Defaults to the canonical\n * `{type}:{provider}:{model}:{hash}` format via {@link KeyBuilder}.\n */\n protected buildKey(input: string, options?: CacheOptions): string {\n return KeyBuilder.build(\n this.cacheType,\n options?.provider ?? 'unknown',\n options?.model ?? 'default',\n input,\n )\n }\n\n /**\n * Wraps a value in a {@link CacheEntry} with computed expiry and metadata.\n */\n protected buildEntry(key: string, value: T, options?: CacheOptions): CacheEntry<T> {\n const createdAt = Date.now()\n const ttl = options?.ttl ?? this.defaultTTL\n const staleTtl = options?.staleTtl\n return {\n key,\n value,\n createdAt,\n expiresAt: TTLManager.computeExpiresAt(createdAt, ttl),\n metadata: {\n compressed: false,\n originalSize: 0,\n cacheType: this.cacheType,\n provider: options?.provider,\n model: options?.model,\n tokenCount: options?.tokenCount,\n staleAt:\n staleTtl !== undefined && staleTtl > 0 ? createdAt + staleTtl : undefined,\n },\n }\n }\n\n /**\n * Cache-aside read-through. Returns the cached value on a hit, otherwise\n * invokes `generator`, stores the result, and returns it. Set\n * `options.cache = false` to bypass the cache for both read and write.\n */\n async getOrGenerate(\n input: string,\n generator: () => Promise<T>,\n options?: CacheOptions,\n ): Promise<T> {\n // A deliberate bypass is neither a hit nor a miss — skip the cache and\n // metrics entirely so accounting reflects only real cache consultations.\n if (options?.cache === false) {\n return generator()\n }\n\n const key = this.buildKey(input, options)\n const start = Date.now()\n\n const cached = await this.adapter.get(key)\n if (cached && !TTLManager.isExpired(cached)) {\n this.hits++\n this.metrics.emit('cache.hit', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n tokensSaved: cached.metadata.tokenCount,\n provider: options?.provider,\n model: options?.model,\n })\n return cached.value\n }\n\n this.misses++\n this.metrics.emit('cache.miss', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n\n return this.coalescedGenerate(key, generator, options, start)\n }\n\n /**\n * Stale-while-revalidate read-through. Like {@link getOrGenerate}, but when a\n * cached entry is *stale* (past its `staleTtl`/`staleAt` but not yet expired)\n * it is returned immediately and a fresh value is fetched in the background.\n * Concurrent stale hits for the same key trigger at most one background\n * refresh. A failed background refresh is swallowed and the stale value stands\n * until it fully expires.\n */\n async getOrRevalidate(\n input: string,\n generator: () => Promise<T>,\n options?: CacheOptions,\n ): Promise<T> {\n if (options?.cache === false) {\n return generator()\n }\n\n const key = this.buildKey(input, options)\n const start = Date.now()\n\n const cached = await this.adapter.get(key)\n if (cached && !TTLManager.isExpired(cached)) {\n this.hits++\n this.metrics.emit('cache.hit', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n tokensSaved: cached.metadata.tokenCount,\n provider: options?.provider,\n model: options?.model,\n })\n const staleAt = cached.metadata.staleAt\n if (staleAt !== undefined && staleAt <= Date.now()) {\n this.revalidate(key, generator, options)\n }\n return cached.value\n }\n\n this.misses++\n this.metrics.emit('cache.miss', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n\n return this.coalescedGenerate(key, generator, options, start)\n }\n\n /**\n * Removes a single entry by its input (and namespacing options).\n */\n async invalidate(input: string, options?: CacheOptions): Promise<void> {\n await this.adapter.delete(this.buildKey(input, options))\n }\n\n /**\n * Returns hit/miss accounting for this manager plus the adapter's entry count.\n */\n async stats(): Promise<CacheStats> {\n const total = this.hits + this.misses\n const adapterStats = await this.adapter.stats()\n return {\n hits: this.hits,\n misses: this.misses,\n hitRate: total === 0 ? 0 : this.hits / total,\n entryCount: adapterStats.entryCount,\n }\n }\n\n /**\n * Runs the generator and persists the result, deduplicating concurrent calls\n * for the same key (singleflight): while one generation is in flight, other\n * callers join it instead of invoking the generator again. Prevents a\n * thundering herd of identical API calls on a cold key.\n */\n private coalescedGenerate(\n key: string,\n generator: () => Promise<T>,\n options: CacheOptions | undefined,\n start: number,\n ): Promise<T> {\n const existing = this.inflight.get(key)\n if (existing) return existing\n\n const promise = (async () => {\n const value = await generator()\n await this.persist(key, value, options, start)\n return value\n })()\n this.inflight.set(key, promise)\n return promise.finally(() => this.inflight.delete(key))\n }\n\n /** Builds an entry, writes it through the adapter, and emits `cache.set`. */\n private async persist(\n key: string,\n value: T,\n options: CacheOptions | undefined,\n start: number,\n ): Promise<void> {\n const ttl = options?.ttl ?? this.defaultTTL\n const entry = this.buildEntry(key, value, options)\n await this.adapter.set(key, entry, ttl)\n this.metrics.emit('cache.set', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n }\n\n /** Fire-and-forget background refresh for a stale entry, coalesced per key. */\n private revalidate(key: string, generator: () => Promise<T>, options?: CacheOptions): void {\n if (this.refreshing.has(key)) return\n this.refreshing.add(key)\n void (async () => {\n try {\n const value = await generator()\n await this.persist(key, value, options, Date.now())\n } catch {\n // Swallow background-refresh failures: the stale value stands until expiry.\n } finally {\n this.refreshing.delete(key)\n }\n })()\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACIO,IAAM,oBAAN,cAAgC,MAAM;AAAA,EAC3C,YAAY,SAAiB,SAAwB;AACnD,UAAM,SAAS,OAAO;AACtB,SAAK,OAAO,WAAW;AAEvB,WAAO,eAAe,MAAM,WAAW,SAAS;AAAA,EAClD;AACF;AAGO,IAAM,oBAAN,cAAgC,kBAAkB;AAAC;AAGnD,IAAM,mBAAN,cAA+B,kBAAkB;AAAC;AAGlD,IAAM,qBAAN,cAAiC,kBAAkB;AAAC;AAGpD,IAAM,kBAAN,cAA8B,kBAAkB;AAAC;;;ACvBxD,yBAA2B;AAYpB,IAAM,aAAN,MAAM,YAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMtB,OAAO,UAAU,MAAsB;AACrC,WAAO,KAAK,KAAK,EAAE,YAAY,EAAE,QAAQ,QAAQ,GAAG;AAAA,EACtD;AAAA;AAAA;AAAA;AAAA,EAKA,OAAO,KAAK,MAAsB;AAChC,eAAO,+BAAW,QAAQ,EAAE,OAAO,YAAW,UAAU,IAAI,CAAC,EAAE,OAAO,KAAK;AAAA,EAC7E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,OAAO,MACL,MACA,UACA,OACA,MACQ;AACR,WAAO,GAAG,IAAI,IAAI,QAAQ,IAAI,KAAK,IAAI,YAAW,KAAK,IAAI,CAAC;AAAA,EAC9D;AACF;;;ACrCO,IAAM,aAAN,MAAM,YAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMtB,OAAO,iBAAiB,WAAmB,KAAkC;AAC3E,QAAI,QAAQ,UAAa,OAAO,EAAG,QAAO;AAC1C,WAAO,YAAY;AAAA,EACrB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,UAAU,OAA+C,MAAc,KAAK,IAAI,GAAY;AACjG,WAAO,MAAM,cAAc,UAAa,MAAM,aAAa;AAAA,EAC7D;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,UAAU,OAA+C,MAAc,KAAK,IAAI,GAAW;AAChG,QAAI,MAAM,cAAc,OAAW,QAAO;AAC1C,WAAO,KAAK,IAAI,GAAG,MAAM,YAAY,GAAG;AAAA,EAC1C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,MAAM,KAAc,MAAc,KAAK,IAAI,GAAuB;AACvE,WAAO,YAAW,iBAAiB,KAAK,GAAG;AAAA,EAC7C;AACF;;;ACtBO,IAAM,iBAAN,MAA2C;AAAA,EAChD,UAAa,OAAkB;AAC7B,QAAI;AACF,aAAO,OAAO,KAAK,KAAK,UAAU,KAAK,GAAG,MAAM;AAAA,IAClD,SAAS,OAAO;AACd,YAAM,IAAI,mBAAmB,qCAAqC,EAAE,MAAM,CAAC;AAAA,IAC7E;AAAA,EACF;AAAA,EAEA,YAAe,MAAiB;AAC9B,QAAI;AACF,aAAO,KAAK,MAAM,KAAK,SAAS,MAAM,CAAC;AAAA,IACzC,SAAS,OAAO;AACd,YAAM,IAAI,mBAAmB,oCAAoC,EAAE,MAAM,CAAC;AAAA,IAC5E;AAAA,EACF;AACF;;;ACxBO,IAAM,cAA2B;AAAA,EACtC,OAAO;AAAA,EAEP;AACF;AAoBO,IAAe,mBAAf,MAAmC;AAAA,EAIrB;AAAA,EACA;AAAA,EACA;AAAA,EAEX,OAAO;AAAA,EACP,SAAS;AAAA;AAAA,EAEA,aAAa,oBAAI,IAAY;AAAA;AAAA,EAE7B,WAAW,oBAAI,IAAwB;AAAA,EAExD,YAAY,SAAqC;AAC/C,SAAK,UAAU,QAAQ;AACvB,SAAK,aAAa,QAAQ;AAC1B,SAAK,UAAU,QAAQ,WAAW;AAAA,EACpC;AAAA;AAAA;AAAA;AAAA;AAAA,EAMU,SAAS,OAAe,SAAgC;AAChE,WAAO,WAAW;AAAA,MAChB,KAAK;AAAA,MACL,SAAS,YAAY;AAAA,MACrB,SAAS,SAAS;AAAA,MAClB;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKU,WAAW,KAAa,OAAU,SAAuC;AACjF,UAAM,YAAY,KAAK,IAAI;AAC3B,UAAM,MAAM,SAAS,OAAO,KAAK;AACjC,UAAM,WAAW,SAAS;AAC1B,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA,WAAW,WAAW,iBAAiB,WAAW,GAAG;AAAA,MACrD,UAAU;AAAA,QACR,YAAY;AAAA,QACZ,cAAc;AAAA,QACd,WAAW,KAAK;AAAA,QAChB,UAAU,SAAS;AAAA,QACnB,OAAO,SAAS;AAAA,QAChB,YAAY,SAAS;AAAA,QACrB,SACE,aAAa,UAAa,WAAW,IAAI,YAAY,WAAW;AAAA,MACpE;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,cACJ,OACA,WACA,SACY;AAGZ,QAAI,SAAS,UAAU,OAAO;AAC5B,aAAO,UAAU;AAAA,IACnB;AAEA,UAAM,MAAM,KAAK,SAAS,OAAO,OAAO;AACxC,UAAM,QAAQ,KAAK,IAAI;AAEvB,UAAM,SAAS,MAAM,KAAK,QAAQ,IAAI,GAAG;AACzC,QAAI,UAAU,CAAC,WAAW,UAAU,MAAM,GAAG;AAC3C,WAAK;AACL,WAAK,QAAQ,KAAK,aAAa;AAAA,QAC7B,WAAW,KAAK;AAAA,QAChB,WAAW,KAAK,IAAI,IAAI;AAAA,QACxB,aAAa,OAAO,SAAS;AAAA,QAC7B,UAAU,SAAS;AAAA,QACnB,OAAO,SAAS;AAAA,MAClB,CAAC;AACD,aAAO,OAAO;AAAA,IAChB;AAEA,SAAK;AACL,SAAK,QAAQ,KAAK,cAAc;AAAA,MAC9B,WAAW,KAAK;AAAA,MAChB,WAAW,KAAK,IAAI,IAAI;AAAA,MACxB,UAAU,SAAS;AAAA,MACnB,OAAO,SAAS;AAAA,IAClB,CAAC;AAED,WAAO,KAAK,kBAAkB,KAAK,WAAW,SAAS,KAAK;AAAA,EAC9D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,gBACJ,OACA,WACA,SACY;AACZ,QAAI,SAAS,UAAU,OAAO;AAC5B,aAAO,UAAU;AAAA,IACnB;AAEA,UAAM,MAAM,KAAK,SAAS,OAAO,OAAO;AACxC,UAAM,QAAQ,KAAK,IAAI;AAEvB,UAAM,SAAS,MAAM,KAAK,QAAQ,IAAI,GAAG;AACzC,QAAI,UAAU,CAAC,WAAW,UAAU,MAAM,GAAG;AAC3C,WAAK;AACL,WAAK,QAAQ,KAAK,aAAa;AAAA,QAC7B,WAAW,KAAK;AAAA,QAChB,WAAW,KAAK,IAAI,IAAI;AAAA,QACxB,aAAa,OAAO,SAAS;AAAA,QAC7B,UAAU,SAAS;AAAA,QACnB,OAAO,SAAS;AAAA,MAClB,CAAC;AACD,YAAM,UAAU,OAAO,SAAS;AAChC,UAAI,YAAY,UAAa,WAAW,KAAK,IAAI,GAAG;AAClD,aAAK,WAAW,KAAK,WAAW,OAAO;AAAA,MACzC;AACA,aAAO,OAAO;AAAA,IAChB;AAEA,SAAK;AACL,SAAK,QAAQ,KAAK,cAAc;AAAA,MAC9B,WAAW,KAAK;AAAA,MAChB,WAAW,KAAK,IAAI,IAAI;AAAA,MACxB,UAAU,SAAS;AAAA,MACnB,OAAO,SAAS;AAAA,IAClB,CAAC;AAED,WAAO,KAAK,kBAAkB,KAAK,WAAW,SAAS,KAAK;AAAA,EAC9D;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,WAAW,OAAe,SAAuC;AACrE,UAAM,KAAK,QAAQ,OAAO,KAAK,SAAS,OAAO,OAAO,CAAC;AAAA,EACzD;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,QAA6B;AACjC,UAAM,QAAQ,KAAK,OAAO,KAAK;AAC/B,UAAM,eAAe,MAAM,KAAK,QAAQ,MAAM;AAC9C,WAAO;AAAA,MACL,MAAM,KAAK;AAAA,MACX,QAAQ,KAAK;AAAA,MACb,SAAS,UAAU,IAAI,IAAI,KAAK,OAAO;AAAA,MACvC,YAAY,aAAa;AAAA,IAC3B;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQQ,kBACN,KACA,WACA,SACA,OACY;AACZ,UAAM,WAAW,KAAK,SAAS,IAAI,GAAG;AACtC,QAAI,SAAU,QAAO;AAErB,UAAM,WAAW,YAAY;AAC3B,YAAM,QAAQ,MAAM,UAAU;AAC9B,YAAM,KAAK,QAAQ,KAAK,OAAO,SAAS,KAAK;AAC7C,aAAO;AAAA,IACT,GAAG;AACH,SAAK,SAAS,IAAI,KAAK,OAAO;AAC9B,WAAO,QAAQ,QAAQ,MAAM,KAAK,SAAS,OAAO,GAAG,CAAC;AAAA,EACxD;AAAA;AAAA,EAGA,MAAc,QACZ,KACA,OACA,SACA,OACe;AACf,UAAM,MAAM,SAAS,OAAO,KAAK;AACjC,UAAM,QAAQ,KAAK,WAAW,KAAK,OAAO,OAAO;AACjD,UAAM,KAAK,QAAQ,IAAI,KAAK,OAAO,GAAG;AACtC,SAAK,QAAQ,KAAK,aAAa;AAAA,MAC7B,WAAW,KAAK;AAAA,MAChB,WAAW,KAAK,IAAI,IAAI;AAAA,MACxB,UAAU,SAAS;AAAA,MACnB,OAAO,SAAS;AAAA,IAClB,CAAC;AAAA,EACH;AAAA;AAAA,EAGQ,WAAW,KAAa,WAA6B,SAA8B;AACzF,QAAI,KAAK,WAAW,IAAI,GAAG,EAAG;AAC9B,SAAK,WAAW,IAAI,GAAG;AACvB,UAAM,YAAY;AAChB,UAAI;AACF,cAAM,QAAQ,MAAM,UAAU;AAC9B,cAAM,KAAK,QAAQ,KAAK,OAAO,SAAS,KAAK,IAAI,CAAC;AAAA,MACpD,QAAQ;AAAA,MAER,UAAE;AACA,aAAK,WAAW,OAAO,GAAG;AAAA,MAC5B;AAAA,IACF,GAAG;AAAA,EACL;AACF;","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -32,6 +32,13 @@ interface CacheMetadata {
|
|
|
32
32
|
provider?: LLMProvider;
|
|
33
33
|
model?: string;
|
|
34
34
|
tokenCount?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Absolute epoch ms after which the entry is considered *stale* but still
|
|
37
|
+
* usable. Used by stale-while-revalidate: a stale hit is served immediately
|
|
38
|
+
* while a fresh value is fetched in the background. Always earlier than
|
|
39
|
+
* `expiresAt`.
|
|
40
|
+
*/
|
|
41
|
+
staleAt?: number;
|
|
35
42
|
}
|
|
36
43
|
/**
|
|
37
44
|
* A single cached value with its bookkeeping. `expiresAt` is an absolute epoch
|
|
@@ -145,6 +152,11 @@ interface CacheOptions {
|
|
|
145
152
|
model?: string;
|
|
146
153
|
/** Relative TTL in milliseconds; overrides the manager default. */
|
|
147
154
|
ttl?: number;
|
|
155
|
+
/**
|
|
156
|
+
* Relative ms after which an entry is *stale* but still servable via
|
|
157
|
+
* `getOrRevalidate` (stale-while-revalidate). Should be less than `ttl`.
|
|
158
|
+
*/
|
|
159
|
+
staleTtl?: number;
|
|
148
160
|
/** When false, bypasses the cache entirely (read and write). */
|
|
149
161
|
cache?: boolean;
|
|
150
162
|
tokenCount?: number;
|
|
@@ -287,6 +299,10 @@ declare abstract class BaseCacheManager<T> {
|
|
|
287
299
|
protected readonly metrics: MetricsSink;
|
|
288
300
|
private hits;
|
|
289
301
|
private misses;
|
|
302
|
+
/** Keys with an in-flight background revalidation, to avoid duplicate refreshes. */
|
|
303
|
+
private readonly refreshing;
|
|
304
|
+
/** In-flight generations, so concurrent misses for the same key run the generator once. */
|
|
305
|
+
private readonly inflight;
|
|
290
306
|
constructor(options: BaseCacheManagerOptions<T>);
|
|
291
307
|
/**
|
|
292
308
|
* Builds the storage key for an input. Defaults to the canonical
|
|
@@ -303,6 +319,15 @@ declare abstract class BaseCacheManager<T> {
|
|
|
303
319
|
* `options.cache = false` to bypass the cache for both read and write.
|
|
304
320
|
*/
|
|
305
321
|
getOrGenerate(input: string, generator: () => Promise<T>, options?: CacheOptions): Promise<T>;
|
|
322
|
+
/**
|
|
323
|
+
* Stale-while-revalidate read-through. Like {@link getOrGenerate}, but when a
|
|
324
|
+
* cached entry is *stale* (past its `staleTtl`/`staleAt` but not yet expired)
|
|
325
|
+
* it is returned immediately and a fresh value is fetched in the background.
|
|
326
|
+
* Concurrent stale hits for the same key trigger at most one background
|
|
327
|
+
* refresh. A failed background refresh is swallowed and the stale value stands
|
|
328
|
+
* until it fully expires.
|
|
329
|
+
*/
|
|
330
|
+
getOrRevalidate(input: string, generator: () => Promise<T>, options?: CacheOptions): Promise<T>;
|
|
306
331
|
/**
|
|
307
332
|
* Removes a single entry by its input (and namespacing options).
|
|
308
333
|
*/
|
|
@@ -311,6 +336,17 @@ declare abstract class BaseCacheManager<T> {
|
|
|
311
336
|
* Returns hit/miss accounting for this manager plus the adapter's entry count.
|
|
312
337
|
*/
|
|
313
338
|
stats(): Promise<CacheStats>;
|
|
339
|
+
/**
|
|
340
|
+
* Runs the generator and persists the result, deduplicating concurrent calls
|
|
341
|
+
* for the same key (singleflight): while one generation is in flight, other
|
|
342
|
+
* callers join it instead of invoking the generator again. Prevents a
|
|
343
|
+
* thundering herd of identical API calls on a cold key.
|
|
344
|
+
*/
|
|
345
|
+
private coalescedGenerate;
|
|
346
|
+
/** Builds an entry, writes it through the adapter, and emits `cache.set`. */
|
|
347
|
+
private persist;
|
|
348
|
+
/** Fire-and-forget background refresh for a stale entry, coalesced per key. */
|
|
349
|
+
private revalidate;
|
|
314
350
|
}
|
|
315
351
|
|
|
316
352
|
export { type AdapterStats, BaseCacheManager, type BaseCacheManagerOptions, CacheAdapterError, type CacheEntry, type CacheMetadata, type CacheOptions, type CacheStats, type CacheType, type CompressedResult, type CompressionAlgo, type CompressionEngine, CompressionError, type CompressionStats, type DataHint, JsonSerializer, KeyBuilder, type LLMProvider, type MetricData, type MetricEvent, type MetricsSink, NodeLLMCacheError, SerializationError, type Serializer, type StorageAdapter, TTLManager, ValidationError, type VectorMatch, type VectorStoreAdapter, noopMetrics };
|
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,13 @@ interface CacheMetadata {
|
|
|
32
32
|
provider?: LLMProvider;
|
|
33
33
|
model?: string;
|
|
34
34
|
tokenCount?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Absolute epoch ms after which the entry is considered *stale* but still
|
|
37
|
+
* usable. Used by stale-while-revalidate: a stale hit is served immediately
|
|
38
|
+
* while a fresh value is fetched in the background. Always earlier than
|
|
39
|
+
* `expiresAt`.
|
|
40
|
+
*/
|
|
41
|
+
staleAt?: number;
|
|
35
42
|
}
|
|
36
43
|
/**
|
|
37
44
|
* A single cached value with its bookkeeping. `expiresAt` is an absolute epoch
|
|
@@ -145,6 +152,11 @@ interface CacheOptions {
|
|
|
145
152
|
model?: string;
|
|
146
153
|
/** Relative TTL in milliseconds; overrides the manager default. */
|
|
147
154
|
ttl?: number;
|
|
155
|
+
/**
|
|
156
|
+
* Relative ms after which an entry is *stale* but still servable via
|
|
157
|
+
* `getOrRevalidate` (stale-while-revalidate). Should be less than `ttl`.
|
|
158
|
+
*/
|
|
159
|
+
staleTtl?: number;
|
|
148
160
|
/** When false, bypasses the cache entirely (read and write). */
|
|
149
161
|
cache?: boolean;
|
|
150
162
|
tokenCount?: number;
|
|
@@ -287,6 +299,10 @@ declare abstract class BaseCacheManager<T> {
|
|
|
287
299
|
protected readonly metrics: MetricsSink;
|
|
288
300
|
private hits;
|
|
289
301
|
private misses;
|
|
302
|
+
/** Keys with an in-flight background revalidation, to avoid duplicate refreshes. */
|
|
303
|
+
private readonly refreshing;
|
|
304
|
+
/** In-flight generations, so concurrent misses for the same key run the generator once. */
|
|
305
|
+
private readonly inflight;
|
|
290
306
|
constructor(options: BaseCacheManagerOptions<T>);
|
|
291
307
|
/**
|
|
292
308
|
* Builds the storage key for an input. Defaults to the canonical
|
|
@@ -303,6 +319,15 @@ declare abstract class BaseCacheManager<T> {
|
|
|
303
319
|
* `options.cache = false` to bypass the cache for both read and write.
|
|
304
320
|
*/
|
|
305
321
|
getOrGenerate(input: string, generator: () => Promise<T>, options?: CacheOptions): Promise<T>;
|
|
322
|
+
/**
|
|
323
|
+
* Stale-while-revalidate read-through. Like {@link getOrGenerate}, but when a
|
|
324
|
+
* cached entry is *stale* (past its `staleTtl`/`staleAt` but not yet expired)
|
|
325
|
+
* it is returned immediately and a fresh value is fetched in the background.
|
|
326
|
+
* Concurrent stale hits for the same key trigger at most one background
|
|
327
|
+
* refresh. A failed background refresh is swallowed and the stale value stands
|
|
328
|
+
* until it fully expires.
|
|
329
|
+
*/
|
|
330
|
+
getOrRevalidate(input: string, generator: () => Promise<T>, options?: CacheOptions): Promise<T>;
|
|
306
331
|
/**
|
|
307
332
|
* Removes a single entry by its input (and namespacing options).
|
|
308
333
|
*/
|
|
@@ -311,6 +336,17 @@ declare abstract class BaseCacheManager<T> {
|
|
|
311
336
|
* Returns hit/miss accounting for this manager plus the adapter's entry count.
|
|
312
337
|
*/
|
|
313
338
|
stats(): Promise<CacheStats>;
|
|
339
|
+
/**
|
|
340
|
+
* Runs the generator and persists the result, deduplicating concurrent calls
|
|
341
|
+
* for the same key (singleflight): while one generation is in flight, other
|
|
342
|
+
* callers join it instead of invoking the generator again. Prevents a
|
|
343
|
+
* thundering herd of identical API calls on a cold key.
|
|
344
|
+
*/
|
|
345
|
+
private coalescedGenerate;
|
|
346
|
+
/** Builds an entry, writes it through the adapter, and emits `cache.set`. */
|
|
347
|
+
private persist;
|
|
348
|
+
/** Fire-and-forget background refresh for a stale entry, coalesced per key. */
|
|
349
|
+
private revalidate;
|
|
314
350
|
}
|
|
315
351
|
|
|
316
352
|
export { type AdapterStats, BaseCacheManager, type BaseCacheManagerOptions, CacheAdapterError, type CacheEntry, type CacheMetadata, type CacheOptions, type CacheStats, type CacheType, type CompressedResult, type CompressionAlgo, type CompressionEngine, CompressionError, type CompressionStats, type DataHint, JsonSerializer, KeyBuilder, type LLMProvider, type MetricData, type MetricEvent, type MetricsSink, NodeLLMCacheError, SerializationError, type Serializer, type StorageAdapter, TTLManager, ValidationError, type VectorMatch, type VectorStoreAdapter, noopMetrics };
|
package/dist/index.mjs
CHANGED
|
@@ -108,6 +108,10 @@ var BaseCacheManager = class {
|
|
|
108
108
|
metrics;
|
|
109
109
|
hits = 0;
|
|
110
110
|
misses = 0;
|
|
111
|
+
/** Keys with an in-flight background revalidation, to avoid duplicate refreshes. */
|
|
112
|
+
refreshing = /* @__PURE__ */ new Set();
|
|
113
|
+
/** In-flight generations, so concurrent misses for the same key run the generator once. */
|
|
114
|
+
inflight = /* @__PURE__ */ new Map();
|
|
111
115
|
constructor(options) {
|
|
112
116
|
this.adapter = options.adapter;
|
|
113
117
|
this.defaultTTL = options.defaultTTL;
|
|
@@ -131,6 +135,7 @@ var BaseCacheManager = class {
|
|
|
131
135
|
buildEntry(key, value, options) {
|
|
132
136
|
const createdAt = Date.now();
|
|
133
137
|
const ttl = options?.ttl ?? this.defaultTTL;
|
|
138
|
+
const staleTtl = options?.staleTtl;
|
|
134
139
|
return {
|
|
135
140
|
key,
|
|
136
141
|
value,
|
|
@@ -142,7 +147,8 @@ var BaseCacheManager = class {
|
|
|
142
147
|
cacheType: this.cacheType,
|
|
143
148
|
provider: options?.provider,
|
|
144
149
|
model: options?.model,
|
|
145
|
-
tokenCount: options?.tokenCount
|
|
150
|
+
tokenCount: options?.tokenCount,
|
|
151
|
+
staleAt: staleTtl !== void 0 && staleTtl > 0 ? createdAt + staleTtl : void 0
|
|
146
152
|
}
|
|
147
153
|
};
|
|
148
154
|
}
|
|
@@ -176,17 +182,46 @@ var BaseCacheManager = class {
|
|
|
176
182
|
provider: options?.provider,
|
|
177
183
|
model: options?.model
|
|
178
184
|
});
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
return this.coalescedGenerate(key, generator, options, start);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Stale-while-revalidate read-through. Like {@link getOrGenerate}, but when a
|
|
189
|
+
* cached entry is *stale* (past its `staleTtl`/`staleAt` but not yet expired)
|
|
190
|
+
* it is returned immediately and a fresh value is fetched in the background.
|
|
191
|
+
* Concurrent stale hits for the same key trigger at most one background
|
|
192
|
+
* refresh. A failed background refresh is swallowed and the stale value stands
|
|
193
|
+
* until it fully expires.
|
|
194
|
+
*/
|
|
195
|
+
async getOrRevalidate(input, generator, options) {
|
|
196
|
+
if (options?.cache === false) {
|
|
197
|
+
return generator();
|
|
198
|
+
}
|
|
199
|
+
const key = this.buildKey(input, options);
|
|
200
|
+
const start = Date.now();
|
|
201
|
+
const cached = await this.adapter.get(key);
|
|
202
|
+
if (cached && !TTLManager.isExpired(cached)) {
|
|
203
|
+
this.hits++;
|
|
204
|
+
this.metrics.emit("cache.hit", {
|
|
205
|
+
cacheType: this.cacheType,
|
|
206
|
+
latencyMs: Date.now() - start,
|
|
207
|
+
tokensSaved: cached.metadata.tokenCount,
|
|
208
|
+
provider: options?.provider,
|
|
209
|
+
model: options?.model
|
|
210
|
+
});
|
|
211
|
+
const staleAt = cached.metadata.staleAt;
|
|
212
|
+
if (staleAt !== void 0 && staleAt <= Date.now()) {
|
|
213
|
+
this.revalidate(key, generator, options);
|
|
214
|
+
}
|
|
215
|
+
return cached.value;
|
|
216
|
+
}
|
|
217
|
+
this.misses++;
|
|
218
|
+
this.metrics.emit("cache.miss", {
|
|
184
219
|
cacheType: this.cacheType,
|
|
185
220
|
latencyMs: Date.now() - start,
|
|
186
221
|
provider: options?.provider,
|
|
187
222
|
model: options?.model
|
|
188
223
|
});
|
|
189
|
-
return
|
|
224
|
+
return this.coalescedGenerate(key, generator, options, start);
|
|
190
225
|
}
|
|
191
226
|
/**
|
|
192
227
|
* Removes a single entry by its input (and namespacing options).
|
|
@@ -207,6 +242,49 @@ var BaseCacheManager = class {
|
|
|
207
242
|
entryCount: adapterStats.entryCount
|
|
208
243
|
};
|
|
209
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Runs the generator and persists the result, deduplicating concurrent calls
|
|
247
|
+
* for the same key (singleflight): while one generation is in flight, other
|
|
248
|
+
* callers join it instead of invoking the generator again. Prevents a
|
|
249
|
+
* thundering herd of identical API calls on a cold key.
|
|
250
|
+
*/
|
|
251
|
+
coalescedGenerate(key, generator, options, start) {
|
|
252
|
+
const existing = this.inflight.get(key);
|
|
253
|
+
if (existing) return existing;
|
|
254
|
+
const promise = (async () => {
|
|
255
|
+
const value = await generator();
|
|
256
|
+
await this.persist(key, value, options, start);
|
|
257
|
+
return value;
|
|
258
|
+
})();
|
|
259
|
+
this.inflight.set(key, promise);
|
|
260
|
+
return promise.finally(() => this.inflight.delete(key));
|
|
261
|
+
}
|
|
262
|
+
/** Builds an entry, writes it through the adapter, and emits `cache.set`. */
|
|
263
|
+
async persist(key, value, options, start) {
|
|
264
|
+
const ttl = options?.ttl ?? this.defaultTTL;
|
|
265
|
+
const entry = this.buildEntry(key, value, options);
|
|
266
|
+
await this.adapter.set(key, entry, ttl);
|
|
267
|
+
this.metrics.emit("cache.set", {
|
|
268
|
+
cacheType: this.cacheType,
|
|
269
|
+
latencyMs: Date.now() - start,
|
|
270
|
+
provider: options?.provider,
|
|
271
|
+
model: options?.model
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/** Fire-and-forget background refresh for a stale entry, coalesced per key. */
|
|
275
|
+
revalidate(key, generator, options) {
|
|
276
|
+
if (this.refreshing.has(key)) return;
|
|
277
|
+
this.refreshing.add(key);
|
|
278
|
+
void (async () => {
|
|
279
|
+
try {
|
|
280
|
+
const value = await generator();
|
|
281
|
+
await this.persist(key, value, options, Date.now());
|
|
282
|
+
} catch {
|
|
283
|
+
} finally {
|
|
284
|
+
this.refreshing.delete(key);
|
|
285
|
+
}
|
|
286
|
+
})();
|
|
287
|
+
}
|
|
210
288
|
};
|
|
211
289
|
export {
|
|
212
290
|
BaseCacheManager,
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/errors.ts","../src/KeyBuilder.ts","../src/TTLManager.ts","../src/Serializer.ts","../src/BaseCacheManager.ts"],"sourcesContent":["/**\n * Base class for every error thrown by NodeLLMCache packages. Catching this\n * catches anything the library throws intentionally.\n */\nexport class NodeLLMCacheError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options)\n this.name = new.target.name\n // Restore prototype chain for instanceof across the ES5 transpile boundary.\n Object.setPrototypeOf(this, new.target.prototype)\n }\n}\n\n/** Thrown when a storage adapter operation fails. */\nexport class CacheAdapterError extends NodeLLMCacheError {}\n\n/** Thrown when compression or decompression fails. */\nexport class CompressionError extends NodeLLMCacheError {}\n\n/** Thrown when serialization or deserialization fails. */\nexport class SerializationError extends NodeLLMCacheError {}\n\n/** Thrown when a value fails validation (e.g. malformed configuration). */\nexport class ValidationError extends NodeLLMCacheError {}\n","import { createHash } from 'node:crypto'\nimport type { CacheType, LLMProvider } from './types.js'\n\n/**\n * Builds deterministic, collision-resistant cache keys.\n *\n * Format: `{type}:{provider}:{model}:{sha256-hash}`\n *\n * The raw input text is normalized and hashed with SHA-256 — keys never\n * contain raw prompt text, which keeps sensitive content out of storage keys\n * and logs.\n */\nexport class KeyBuilder {\n /**\n * Normalizes text before hashing so trivially different inputs collapse to\n * the same key: trims surrounding whitespace, lowercases, and collapses any\n * run of whitespace to a single space.\n */\n static normalize(text: string): string {\n return text.trim().toLowerCase().replace(/\\s+/g, ' ')\n }\n\n /**\n * Produces the SHA-256 hex digest of the normalized text.\n */\n static hash(text: string): string {\n return createHash('sha256').update(KeyBuilder.normalize(text)).digest('hex')\n }\n\n /**\n * Builds a fully namespaced cache key.\n *\n * @example\n * KeyBuilder.build('prompt', 'openai', 'gpt-4o', 'hello world')\n * // 'prompt:openai:gpt-4o:b94d27b9...'\n */\n static build(\n type: CacheType,\n provider: LLMProvider | string,\n model: string,\n text: string,\n ): string {\n return `${type}:${provider}:${model}:${KeyBuilder.hash(text)}`\n }\n}\n","import type { CacheEntry } from './interfaces.js'\n\n/**\n * Centralizes time-to-live arithmetic: computing expiry timestamps, checking\n * expiry, and supporting sliding-window refresh. All durations are relative\n * milliseconds; all timestamps are absolute epoch milliseconds.\n */\nexport class TTLManager {\n /**\n * Computes the absolute expiry timestamp for an entry created at `createdAt`\n * with a relative `ttl`. Returns `undefined` when `ttl` is not a positive\n * number, meaning the entry never expires.\n */\n static computeExpiresAt(createdAt: number, ttl?: number): number | undefined {\n if (ttl === undefined || ttl <= 0) return undefined\n return createdAt + ttl\n }\n\n /**\n * Returns true when the entry has an expiry and that expiry is at or before\n * `now` (defaults to the current time).\n */\n static isExpired(entry: Pick<CacheEntry<unknown>, 'expiresAt'>, now: number = Date.now()): boolean {\n return entry.expiresAt !== undefined && entry.expiresAt <= now\n }\n\n /**\n * Milliseconds remaining until the entry expires. Returns `Infinity` for\n * entries with no expiry, and `0` for already-expired entries.\n */\n static remaining(entry: Pick<CacheEntry<unknown>, 'expiresAt'>, now: number = Date.now()): number {\n if (entry.expiresAt === undefined) return Infinity\n return Math.max(0, entry.expiresAt - now)\n }\n\n /**\n * Computes a refreshed expiry for a sliding-window TTL: extends the entry's\n * life by `ttl` from `now`. Returns `undefined` when `ttl` is not positive.\n */\n static slide(ttl?: number, now: number = Date.now()): number | undefined {\n return TTLManager.computeExpiresAt(now, ttl)\n }\n}\n","import { SerializationError } from './errors.js'\n\n/**\n * Converts values to and from `Buffer` for storage. Implementations decide the\n * wire format.\n *\n * The architecture calls for MessagePack as the primary format, but\n * `@nodellmcache/core` must stay dependency-free, so it ships only the JSON\n * implementation below. A MessagePack serializer can be supplied by an optional\n * package and injected wherever a `Serializer` is accepted.\n */\nexport interface Serializer {\n serialize<T>(value: T): Buffer\n deserialize<T>(data: Buffer): T\n}\n\n/**\n * Default JSON-backed serializer. Zero dependencies; handles the common case\n * and wraps failures in {@link SerializationError}.\n */\nexport class JsonSerializer implements Serializer {\n serialize<T>(value: T): Buffer {\n try {\n return Buffer.from(JSON.stringify(value), 'utf8')\n } catch (cause) {\n throw new SerializationError('Failed to serialize value to JSON', { cause })\n }\n }\n\n deserialize<T>(data: Buffer): T {\n try {\n return JSON.parse(data.toString('utf8')) as T\n } catch (cause) {\n throw new SerializationError('Failed to deserialize JSON value', { cause })\n }\n }\n}\n","import { KeyBuilder } from './KeyBuilder.js'\nimport { TTLManager } from './TTLManager.js'\nimport type {\n CacheEntry,\n CacheOptions,\n CacheStats,\n MetricsSink,\n StorageAdapter,\n} from './interfaces.js'\nimport type { CacheType } from './types.js'\n\n/** A metrics sink that discards everything; the default when none is injected. */\nexport const noopMetrics: MetricsSink = {\n emit() {\n // intentionally empty\n },\n}\n\n/**\n * Construction options shared by all cache managers.\n */\nexport interface BaseCacheManagerOptions<T> {\n adapter: StorageAdapter<T>\n /** Default relative TTL in milliseconds applied to entries lacking their own. */\n defaultTTL?: number\n /** Metrics sink; defaults to a no-op so core stays dependency-free. */\n metrics?: MetricsSink\n}\n\n/**\n * Shared base for every feature cache (prompt, embedding, semantic, ...).\n *\n * Provides the cache-aside `getOrGenerate` flow, key building, entry\n * construction, invalidation, and hit/miss accounting. Subclasses declare their\n * {@link CacheType} and may override {@link buildKey} for custom namespacing.\n */\nexport abstract class BaseCacheManager<T> {\n /** The workload category for keys and metrics. */\n protected abstract readonly cacheType: CacheType\n\n protected readonly adapter: StorageAdapter<T>\n protected readonly defaultTTL: number | undefined\n protected readonly metrics: MetricsSink\n\n private hits = 0\n private misses = 0\n\n constructor(options: BaseCacheManagerOptions<T>) {\n this.adapter = options.adapter\n this.defaultTTL = options.defaultTTL\n this.metrics = options.metrics ?? noopMetrics\n }\n\n /**\n * Builds the storage key for an input. Defaults to the canonical\n * `{type}:{provider}:{model}:{hash}` format via {@link KeyBuilder}.\n */\n protected buildKey(input: string, options?: CacheOptions): string {\n return KeyBuilder.build(\n this.cacheType,\n options?.provider ?? 'unknown',\n options?.model ?? 'default',\n input,\n )\n }\n\n /**\n * Wraps a value in a {@link CacheEntry} with computed expiry and metadata.\n */\n protected buildEntry(key: string, value: T, options?: CacheOptions): CacheEntry<T> {\n const createdAt = Date.now()\n const ttl = options?.ttl ?? this.defaultTTL\n return {\n key,\n value,\n createdAt,\n expiresAt: TTLManager.computeExpiresAt(createdAt, ttl),\n metadata: {\n compressed: false,\n originalSize: 0,\n cacheType: this.cacheType,\n provider: options?.provider,\n model: options?.model,\n tokenCount: options?.tokenCount,\n },\n }\n }\n\n /**\n * Cache-aside read-through. Returns the cached value on a hit, otherwise\n * invokes `generator`, stores the result, and returns it. Set\n * `options.cache = false` to bypass the cache for both read and write.\n */\n async getOrGenerate(\n input: string,\n generator: () => Promise<T>,\n options?: CacheOptions,\n ): Promise<T> {\n // A deliberate bypass is neither a hit nor a miss — skip the cache and\n // metrics entirely so accounting reflects only real cache consultations.\n if (options?.cache === false) {\n return generator()\n }\n\n const key = this.buildKey(input, options)\n const start = Date.now()\n\n const cached = await this.adapter.get(key)\n if (cached && !TTLManager.isExpired(cached)) {\n this.hits++\n this.metrics.emit('cache.hit', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n tokensSaved: cached.metadata.tokenCount,\n provider: options?.provider,\n model: options?.model,\n })\n return cached.value\n }\n\n this.misses++\n this.metrics.emit('cache.miss', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n\n const value = await generator()\n\n const ttl = options?.ttl ?? this.defaultTTL\n const entry = this.buildEntry(key, value, options)\n await this.adapter.set(key, entry, ttl)\n this.metrics.emit('cache.set', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n\n return value\n }\n\n /**\n * Removes a single entry by its input (and namespacing options).\n */\n async invalidate(input: string, options?: CacheOptions): Promise<void> {\n await this.adapter.delete(this.buildKey(input, options))\n }\n\n /**\n * Returns hit/miss accounting for this manager plus the adapter's entry count.\n */\n async stats(): Promise<CacheStats> {\n const total = this.hits + this.misses\n const adapterStats = await this.adapter.stats()\n return {\n hits: this.hits,\n misses: this.misses,\n hitRate: total === 0 ? 0 : this.hits / total,\n entryCount: adapterStats.entryCount,\n }\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts","../src/KeyBuilder.ts","../src/TTLManager.ts","../src/Serializer.ts","../src/BaseCacheManager.ts"],"sourcesContent":["/**\n * Base class for every error thrown by NodeLLMCache packages. Catching this\n * catches anything the library throws intentionally.\n */\nexport class NodeLLMCacheError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options)\n this.name = new.target.name\n // Restore prototype chain for instanceof across the ES5 transpile boundary.\n Object.setPrototypeOf(this, new.target.prototype)\n }\n}\n\n/** Thrown when a storage adapter operation fails. */\nexport class CacheAdapterError extends NodeLLMCacheError {}\n\n/** Thrown when compression or decompression fails. */\nexport class CompressionError extends NodeLLMCacheError {}\n\n/** Thrown when serialization or deserialization fails. */\nexport class SerializationError extends NodeLLMCacheError {}\n\n/** Thrown when a value fails validation (e.g. malformed configuration). */\nexport class ValidationError extends NodeLLMCacheError {}\n","import { createHash } from 'node:crypto'\nimport type { CacheType, LLMProvider } from './types.js'\n\n/**\n * Builds deterministic, collision-resistant cache keys.\n *\n * Format: `{type}:{provider}:{model}:{sha256-hash}`\n *\n * The raw input text is normalized and hashed with SHA-256 — keys never\n * contain raw prompt text, which keeps sensitive content out of storage keys\n * and logs.\n */\nexport class KeyBuilder {\n /**\n * Normalizes text before hashing so trivially different inputs collapse to\n * the same key: trims surrounding whitespace, lowercases, and collapses any\n * run of whitespace to a single space.\n */\n static normalize(text: string): string {\n return text.trim().toLowerCase().replace(/\\s+/g, ' ')\n }\n\n /**\n * Produces the SHA-256 hex digest of the normalized text.\n */\n static hash(text: string): string {\n return createHash('sha256').update(KeyBuilder.normalize(text)).digest('hex')\n }\n\n /**\n * Builds a fully namespaced cache key.\n *\n * @example\n * KeyBuilder.build('prompt', 'openai', 'gpt-4o', 'hello world')\n * // 'prompt:openai:gpt-4o:b94d27b9...'\n */\n static build(\n type: CacheType,\n provider: LLMProvider | string,\n model: string,\n text: string,\n ): string {\n return `${type}:${provider}:${model}:${KeyBuilder.hash(text)}`\n }\n}\n","import type { CacheEntry } from './interfaces.js'\n\n/**\n * Centralizes time-to-live arithmetic: computing expiry timestamps, checking\n * expiry, and supporting sliding-window refresh. All durations are relative\n * milliseconds; all timestamps are absolute epoch milliseconds.\n */\nexport class TTLManager {\n /**\n * Computes the absolute expiry timestamp for an entry created at `createdAt`\n * with a relative `ttl`. Returns `undefined` when `ttl` is not a positive\n * number, meaning the entry never expires.\n */\n static computeExpiresAt(createdAt: number, ttl?: number): number | undefined {\n if (ttl === undefined || ttl <= 0) return undefined\n return createdAt + ttl\n }\n\n /**\n * Returns true when the entry has an expiry and that expiry is at or before\n * `now` (defaults to the current time).\n */\n static isExpired(entry: Pick<CacheEntry<unknown>, 'expiresAt'>, now: number = Date.now()): boolean {\n return entry.expiresAt !== undefined && entry.expiresAt <= now\n }\n\n /**\n * Milliseconds remaining until the entry expires. Returns `Infinity` for\n * entries with no expiry, and `0` for already-expired entries.\n */\n static remaining(entry: Pick<CacheEntry<unknown>, 'expiresAt'>, now: number = Date.now()): number {\n if (entry.expiresAt === undefined) return Infinity\n return Math.max(0, entry.expiresAt - now)\n }\n\n /**\n * Computes a refreshed expiry for a sliding-window TTL: extends the entry's\n * life by `ttl` from `now`. Returns `undefined` when `ttl` is not positive.\n */\n static slide(ttl?: number, now: number = Date.now()): number | undefined {\n return TTLManager.computeExpiresAt(now, ttl)\n }\n}\n","import { SerializationError } from './errors.js'\n\n/**\n * Converts values to and from `Buffer` for storage. Implementations decide the\n * wire format.\n *\n * The architecture calls for MessagePack as the primary format, but\n * `@nodellmcache/core` must stay dependency-free, so it ships only the JSON\n * implementation below. A MessagePack serializer can be supplied by an optional\n * package and injected wherever a `Serializer` is accepted.\n */\nexport interface Serializer {\n serialize<T>(value: T): Buffer\n deserialize<T>(data: Buffer): T\n}\n\n/**\n * Default JSON-backed serializer. Zero dependencies; handles the common case\n * and wraps failures in {@link SerializationError}.\n */\nexport class JsonSerializer implements Serializer {\n serialize<T>(value: T): Buffer {\n try {\n return Buffer.from(JSON.stringify(value), 'utf8')\n } catch (cause) {\n throw new SerializationError('Failed to serialize value to JSON', { cause })\n }\n }\n\n deserialize<T>(data: Buffer): T {\n try {\n return JSON.parse(data.toString('utf8')) as T\n } catch (cause) {\n throw new SerializationError('Failed to deserialize JSON value', { cause })\n }\n }\n}\n","import { KeyBuilder } from './KeyBuilder.js'\nimport { TTLManager } from './TTLManager.js'\nimport type {\n CacheEntry,\n CacheOptions,\n CacheStats,\n MetricsSink,\n StorageAdapter,\n} from './interfaces.js'\nimport type { CacheType } from './types.js'\n\n/** A metrics sink that discards everything; the default when none is injected. */\nexport const noopMetrics: MetricsSink = {\n emit() {\n // intentionally empty\n },\n}\n\n/**\n * Construction options shared by all cache managers.\n */\nexport interface BaseCacheManagerOptions<T> {\n adapter: StorageAdapter<T>\n /** Default relative TTL in milliseconds applied to entries lacking their own. */\n defaultTTL?: number\n /** Metrics sink; defaults to a no-op so core stays dependency-free. */\n metrics?: MetricsSink\n}\n\n/**\n * Shared base for every feature cache (prompt, embedding, semantic, ...).\n *\n * Provides the cache-aside `getOrGenerate` flow, key building, entry\n * construction, invalidation, and hit/miss accounting. Subclasses declare their\n * {@link CacheType} and may override {@link buildKey} for custom namespacing.\n */\nexport abstract class BaseCacheManager<T> {\n /** The workload category for keys and metrics. */\n protected abstract readonly cacheType: CacheType\n\n protected readonly adapter: StorageAdapter<T>\n protected readonly defaultTTL: number | undefined\n protected readonly metrics: MetricsSink\n\n private hits = 0\n private misses = 0\n /** Keys with an in-flight background revalidation, to avoid duplicate refreshes. */\n private readonly refreshing = new Set<string>()\n /** In-flight generations, so concurrent misses for the same key run the generator once. */\n private readonly inflight = new Map<string, Promise<T>>()\n\n constructor(options: BaseCacheManagerOptions<T>) {\n this.adapter = options.adapter\n this.defaultTTL = options.defaultTTL\n this.metrics = options.metrics ?? noopMetrics\n }\n\n /**\n * Builds the storage key for an input. Defaults to the canonical\n * `{type}:{provider}:{model}:{hash}` format via {@link KeyBuilder}.\n */\n protected buildKey(input: string, options?: CacheOptions): string {\n return KeyBuilder.build(\n this.cacheType,\n options?.provider ?? 'unknown',\n options?.model ?? 'default',\n input,\n )\n }\n\n /**\n * Wraps a value in a {@link CacheEntry} with computed expiry and metadata.\n */\n protected buildEntry(key: string, value: T, options?: CacheOptions): CacheEntry<T> {\n const createdAt = Date.now()\n const ttl = options?.ttl ?? this.defaultTTL\n const staleTtl = options?.staleTtl\n return {\n key,\n value,\n createdAt,\n expiresAt: TTLManager.computeExpiresAt(createdAt, ttl),\n metadata: {\n compressed: false,\n originalSize: 0,\n cacheType: this.cacheType,\n provider: options?.provider,\n model: options?.model,\n tokenCount: options?.tokenCount,\n staleAt:\n staleTtl !== undefined && staleTtl > 0 ? createdAt + staleTtl : undefined,\n },\n }\n }\n\n /**\n * Cache-aside read-through. Returns the cached value on a hit, otherwise\n * invokes `generator`, stores the result, and returns it. Set\n * `options.cache = false` to bypass the cache for both read and write.\n */\n async getOrGenerate(\n input: string,\n generator: () => Promise<T>,\n options?: CacheOptions,\n ): Promise<T> {\n // A deliberate bypass is neither a hit nor a miss — skip the cache and\n // metrics entirely so accounting reflects only real cache consultations.\n if (options?.cache === false) {\n return generator()\n }\n\n const key = this.buildKey(input, options)\n const start = Date.now()\n\n const cached = await this.adapter.get(key)\n if (cached && !TTLManager.isExpired(cached)) {\n this.hits++\n this.metrics.emit('cache.hit', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n tokensSaved: cached.metadata.tokenCount,\n provider: options?.provider,\n model: options?.model,\n })\n return cached.value\n }\n\n this.misses++\n this.metrics.emit('cache.miss', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n\n return this.coalescedGenerate(key, generator, options, start)\n }\n\n /**\n * Stale-while-revalidate read-through. Like {@link getOrGenerate}, but when a\n * cached entry is *stale* (past its `staleTtl`/`staleAt` but not yet expired)\n * it is returned immediately and a fresh value is fetched in the background.\n * Concurrent stale hits for the same key trigger at most one background\n * refresh. A failed background refresh is swallowed and the stale value stands\n * until it fully expires.\n */\n async getOrRevalidate(\n input: string,\n generator: () => Promise<T>,\n options?: CacheOptions,\n ): Promise<T> {\n if (options?.cache === false) {\n return generator()\n }\n\n const key = this.buildKey(input, options)\n const start = Date.now()\n\n const cached = await this.adapter.get(key)\n if (cached && !TTLManager.isExpired(cached)) {\n this.hits++\n this.metrics.emit('cache.hit', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n tokensSaved: cached.metadata.tokenCount,\n provider: options?.provider,\n model: options?.model,\n })\n const staleAt = cached.metadata.staleAt\n if (staleAt !== undefined && staleAt <= Date.now()) {\n this.revalidate(key, generator, options)\n }\n return cached.value\n }\n\n this.misses++\n this.metrics.emit('cache.miss', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n\n return this.coalescedGenerate(key, generator, options, start)\n }\n\n /**\n * Removes a single entry by its input (and namespacing options).\n */\n async invalidate(input: string, options?: CacheOptions): Promise<void> {\n await this.adapter.delete(this.buildKey(input, options))\n }\n\n /**\n * Returns hit/miss accounting for this manager plus the adapter's entry count.\n */\n async stats(): Promise<CacheStats> {\n const total = this.hits + this.misses\n const adapterStats = await this.adapter.stats()\n return {\n hits: this.hits,\n misses: this.misses,\n hitRate: total === 0 ? 0 : this.hits / total,\n entryCount: adapterStats.entryCount,\n }\n }\n\n /**\n * Runs the generator and persists the result, deduplicating concurrent calls\n * for the same key (singleflight): while one generation is in flight, other\n * callers join it instead of invoking the generator again. Prevents a\n * thundering herd of identical API calls on a cold key.\n */\n private coalescedGenerate(\n key: string,\n generator: () => Promise<T>,\n options: CacheOptions | undefined,\n start: number,\n ): Promise<T> {\n const existing = this.inflight.get(key)\n if (existing) return existing\n\n const promise = (async () => {\n const value = await generator()\n await this.persist(key, value, options, start)\n return value\n })()\n this.inflight.set(key, promise)\n return promise.finally(() => this.inflight.delete(key))\n }\n\n /** Builds an entry, writes it through the adapter, and emits `cache.set`. */\n private async persist(\n key: string,\n value: T,\n options: CacheOptions | undefined,\n start: number,\n ): Promise<void> {\n const ttl = options?.ttl ?? this.defaultTTL\n const entry = this.buildEntry(key, value, options)\n await this.adapter.set(key, entry, ttl)\n this.metrics.emit('cache.set', {\n cacheType: this.cacheType,\n latencyMs: Date.now() - start,\n provider: options?.provider,\n model: options?.model,\n })\n }\n\n /** Fire-and-forget background refresh for a stale entry, coalesced per key. */\n private revalidate(key: string, generator: () => Promise<T>, options?: CacheOptions): void {\n if (this.refreshing.has(key)) return\n this.refreshing.add(key)\n void (async () => {\n try {\n const value = await generator()\n await this.persist(key, value, options, Date.now())\n } catch {\n // Swallow background-refresh failures: the stale value stands until expiry.\n } finally {\n this.refreshing.delete(key)\n }\n })()\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodellmcache/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Shared interfaces, types, and utilities for NodeLLMCache — AI memory infrastructure for Node.js",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"llm",
|
|
7
|
+
"cache",
|
|
8
|
+
"ai",
|
|
9
|
+
"memory",
|
|
10
|
+
"nodejs",
|
|
11
|
+
"embeddings"
|
|
12
|
+
],
|
|
6
13
|
"license": "MIT",
|
|
7
14
|
"repository": {
|
|
8
15
|
"type": "git",
|
|
@@ -22,15 +29,11 @@
|
|
|
22
29
|
"require": "./dist/index.cjs"
|
|
23
30
|
}
|
|
24
31
|
},
|
|
25
|
-
"files": [
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
"test:coverage": "vitest run --coverage",
|
|
31
|
-
"typecheck": "tsc --noEmit",
|
|
32
|
-
"lint": "echo \"no lint configured\""
|
|
33
|
-
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"CHANGELOG.md"
|
|
36
|
+
],
|
|
34
37
|
"devDependencies": {
|
|
35
38
|
"@types/node": "^20.0.0",
|
|
36
39
|
"tsup": "^8.0.0",
|
|
@@ -40,5 +43,13 @@
|
|
|
40
43
|
},
|
|
41
44
|
"publishConfig": {
|
|
42
45
|
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsup",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest",
|
|
51
|
+
"test:coverage": "vitest run --coverage",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"lint": "echo \"no lint configured\""
|
|
43
54
|
}
|
|
44
|
-
}
|
|
55
|
+
}
|