From b95d22663a34ab5ce39e6f86538b81647d6cbca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 21:20:05 +0000 Subject: [PATCH 1/2] feat(cacheable, memory): add maxTtl option to cap maximum time-to-live Adds a `maxTtl` option to both `Cacheable` and `CacheableMemory` that enforces an upper bound on any TTL in the cache. When set, per-entry TTLs exceeding maxTtl are capped, and entries with no TTL are also bounded. Default is `undefined` (no maximum). Includes full test coverage and documentation updates. https://claude.ai/code/session_01Gdiqk2rKNc1Vji61zm3qmy --- packages/cacheable/README.md | 33 ++++++ packages/cacheable/src/index.ts | 75 +++++++++++- packages/cacheable/src/types.ts | 7 ++ packages/cacheable/test/index.test.ts | 164 ++++++++++++++++++++++++++ packages/memory/README.md | 31 +++++ packages/memory/src/index.ts | 46 ++++++++ packages/memory/test/index.test.ts | 161 +++++++++++++++++++++++++ 7 files changed, 514 insertions(+), 3 deletions(-) diff --git a/packages/cacheable/README.md b/packages/cacheable/README.md index 07f66a0f..0beb6f39 100644 --- a/packages/cacheable/README.md +++ b/packages/cacheable/README.md @@ -32,6 +32,7 @@ * [Storage Tiering and Caching](#storage-tiering-and-caching) * [TTL Propagation and Storage Tiering](#ttl-propagation-and-storage-tiering) * [Shorthand for Time to Live (ttl)](#shorthand-for-time-to-live-ttl) +* [Maximum Time to Live (maxTtl)](#maximum-time-to-live-maxttl) * [Iteration on Primary and Secondary Stores](#iteration-on-primary-and-secondary-stores) * [Non-Blocking Operations](#non-blocking-operations) * [Non-Blocking with @keyv/redis](#non-blocking-with-keyvredis) @@ -607,6 +608,36 @@ const value = await serviceB.get('config'); // undefined * The sync feature requires a message provider to be running and accessible by all cache instances. * Each cache instance has a unique `cacheId`. Events are only applied if they come from a different instance, preventing infinite loops. +# Maximum Time to Live (maxTtl) + +You can set a `maxTtl` option to enforce an upper bound on any TTL in the cache. When `maxTtl` is set: +- Any per-entry TTL that exceeds `maxTtl` will be capped to `maxTtl`. +- Entries with no TTL (that would otherwise live indefinitely) will be capped to `maxTtl`. +- The default TTL is still respected if it is within the `maxTtl` limit. +- The `maxTtl` is enforced on both primary and secondary stores. + +This is useful when you want to guarantee that no cache entry lives longer than a certain duration, regardless of what TTL is passed to individual `set()` calls. + +```javascript +import { Cacheable } from 'cacheable'; + +// No entry can live longer than 1 hour +const cache = new Cacheable({ maxTtl: '1h' }); + +await cache.set('key1', 'value1', '2h'); // capped to 1 hour +await cache.set('key2', 'value2'); // also capped to 1 hour (would otherwise be indefinite) +await cache.set('key3', 'value3', '30m'); // 30 minutes is within maxTtl, so it stays as-is +``` + +You can also set `maxTtl` after construction: + +```javascript +const cache = new Cacheable(); +cache.maxTtl = 5000; // 5 seconds max +cache.maxTtl = '10m'; // 10 minutes max +cache.maxTtl = undefined; // disable maxTtl (no upper bound) +``` + # Cacheable Options The following options are available for you to configure `cacheable`: @@ -616,6 +647,7 @@ The following options are available for you to configure `cacheable`: * `nonBlocking`: If the secondary store is non-blocking. Default is `false`. * `stats`: To enable statistics for this instance. Default is `false`. * `ttl`: The default time to live for the cache in milliseconds. Default is `undefined` which is disabled. +* `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Enforced on both primary and secondary stores. Default is `undefined` (no maximum). * `namespace`: The namespace for the cache. Default is `undefined`. * `cacheId`: A unique identifier for this cache instance. Used for sync filtering. Default is a random string. * `sync`: Enable distributed cache synchronization. Can be: @@ -667,6 +699,7 @@ _This does not enable statistics for your layer 2 cache as that is a distributed * `primary`: The primary store for the cache (layer 1) defaults to in-memory by Keyv. * `secondary`: The secondary store for the cache (layer 2) usually a persistent cache by Keyv. * `namespace`: The namespace for the cache. Default is `undefined`. This will set the namespace for the primary and secondary stores. +* `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Default is `undefined` (no maximum). * `nonBlocking`: If the secondary store is non-blocking. Default is `false`. * `stats`: The statistics for this instance which includes `hits`, `misses`, `sets`, `deletes`, `clears`, `errors`, `count`, `vsize`, `ksize`. diff --git a/packages/cacheable/src/index.ts b/packages/cacheable/src/index.ts index 6e5a0608..a2bfbd19 100644 --- a/packages/cacheable/src/index.ts +++ b/packages/cacheable/src/index.ts @@ -33,6 +33,7 @@ export class Cacheable extends Hookified { private _secondary: Keyv | undefined; private _nonBlocking = false; private _ttl?: number | string; + private _maxTtl?: number | string; private readonly _stats = new CacheableStats({ enabled: false }); private _namespace?: string | (() => string); private _cacheId: string = Math.random().toString(36).slice(2); @@ -64,6 +65,10 @@ export class Cacheable extends Hookified { this.setTtl(options.ttl); } + if (options?.maxTtl !== undefined) { + this.setMaxTtl(options.maxTtl); + } + if (options?.cacheId) { this._cacheId = options.cacheId; } @@ -223,6 +228,40 @@ export class Cacheable extends Hookified { this.setTtl(ttl); } + /** + * Gets the maximum time-to-live for the cacheable instance. When set, any TTL that exceeds this + * value is capped to maxTtl. Entries with no TTL will also be capped to maxTtl. + * Can be a number in milliseconds or a human-readable format such as `1s`, `1m`, `1h`, `1d`. + * Default is `undefined` (no maximum). + * + * @returns {number | string | undefined} The maximum time-to-live or undefined if not set + * @example + * ```typescript + * const cacheable = new Cacheable({ maxTtl: '1h' }); + * console.log(cacheable.maxTtl); // '1h' + * ``` + */ + public get maxTtl(): number | string | undefined { + return this._maxTtl; + } + + /** + * Sets the maximum time-to-live for the cacheable instance. When set, any TTL that exceeds this + * value is capped to maxTtl. Entries with no TTL will also be capped to maxTtl. + * If you set a number it is milliseconds, if you set a string it is a human-readable + * format such as `1s` for 1 second or `1h` for 1 hour. Setting undefined disables the maximum. + * + * @param {number | string | undefined} maxTtl The maximum time-to-live + * @example + * ```typescript + * const cacheable = new Cacheable(); + * cacheable.maxTtl = '1h'; // Set the max TTL to 1 hour + * ``` + */ + public set maxTtl(maxTtl: number | string | undefined) { + this.setMaxTtl(maxTtl); + } + /** * The cacheId for the cacheable instance. This is primarily used for the wrap function to not have conflicts. * If it is not set then it will be a random string that is generated @@ -517,21 +556,24 @@ export class Cacheable extends Hookified { ): Promise { let result = false; const explicitTtl = shorthandToMilliseconds(ttl); + const maxTtlMs = shorthandToMilliseconds(this._maxTtl); try { - const primaryTtl = getCascadingTtl( + let primaryTtl = getCascadingTtl( this._ttl, this._primary.ttl, explicitTtl, ); + primaryTtl = this.capTtl(primaryTtl, maxTtlMs); const item = { key, value, ttl: primaryTtl }; await this.hook(CacheableHooks.BEFORE_SET, item); const hookOverridden = item.ttl !== primaryTtl; const promises = []; promises.push(this._primary.set(item.key, item.value, item.ttl)); if (this._secondary) { - const secondaryTtl = hookOverridden + let secondaryTtl = hookOverridden ? item.ttl : getCascadingTtl(this._ttl, this._secondary.ttl, explicitTtl); + secondaryTtl = this.capTtl(secondaryTtl, maxTtlMs); promises.push(this._secondary.set(item.key, item.value, secondaryTtl)); } @@ -945,13 +987,15 @@ export class Cacheable extends Hookified { keyv: Keyv, items: CacheableItem[], ): Promise { + const maxTtlMs = shorthandToMilliseconds(this._maxTtl); const entries: KeyvEntry[] = []; for (const item of items) { - const finalTtl = getCascadingTtl( + let finalTtl = getCascadingTtl( this._ttl, keyv.ttl, shorthandToMilliseconds(item.ttl), ); + finalTtl = this.capTtl(finalTtl, maxTtlMs); entries.push({ key: item.key, value: item.value, ttl: finalTtl }); } @@ -1201,6 +1245,31 @@ export class Cacheable extends Hookified { this._ttl = undefined; } } + + private setMaxTtl(maxTtl: number | string | undefined): void { + if (typeof maxTtl === "string" || maxTtl === undefined) { + this._maxTtl = maxTtl; + } else if (maxTtl > 0) { + this._maxTtl = maxTtl; + } else { + this._maxTtl = undefined; + } + } + + private capTtl( + ttl: number | undefined, + maxTtlMs: number | undefined, + ): number | undefined { + if (maxTtlMs === undefined) { + return ttl; + } + + if (ttl === undefined) { + return maxTtlMs; + } + + return Math.min(ttl, maxTtlMs); + } } export { diff --git a/packages/cacheable/src/types.ts b/packages/cacheable/src/types.ts index 1de2005e..5ddb8dac 100644 --- a/packages/cacheable/src/types.ts +++ b/packages/cacheable/src/types.ts @@ -43,6 +43,13 @@ export type CacheableOptions = { * or undefined if there is no time-to-live. */ ttl?: number | string; + /** + * The maximum time-to-live for the cacheable instance. When set, any TTL that exceeds this value + * is capped to maxTtl. Entries with no TTL will also be capped to maxTtl. + * Can be a number in milliseconds or a human-readable format such as `1s`, `1m`, `1h`, `1d`. + * Default is `undefined` (no maximum). + */ + maxTtl?: number | string; /** * The namespace for the cacheable instance. It can be a string or a function that returns a string. */ diff --git a/packages/cacheable/test/index.test.ts b/packages/cacheable/test/index.test.ts index 88deb941..3d1b87fd 100644 --- a/packages/cacheable/test/index.test.ts +++ b/packages/cacheable/test/index.test.ts @@ -1816,3 +1816,167 @@ describe("Non-blocking error handling", () => { ); }); }); + +describe("cacheable maxTtl", () => { + test("should have default maxTtl as undefined", () => { + const cacheable = new Cacheable(); + expect(cacheable.maxTtl).toBeUndefined(); + }); + + test("should set maxTtl via constructor with number", () => { + const cacheable = new Cacheable({ maxTtl: 5000 }); + expect(cacheable.maxTtl).toBe(5000); + }); + + test("should set maxTtl via constructor with string", () => { + const cacheable = new Cacheable({ maxTtl: "1h" }); + expect(cacheable.maxTtl).toBe("1h"); + }); + + test("should set maxTtl via setter", () => { + const cacheable = new Cacheable(); + cacheable.maxTtl = 10_000; + expect(cacheable.maxTtl).toBe(10_000); + }); + + test("should set maxTtl via setter with string", () => { + const cacheable = new Cacheable(); + cacheable.maxTtl = "30m"; + expect(cacheable.maxTtl).toBe("30m"); + }); + + test("should disable maxTtl by setting to undefined", () => { + const cacheable = new Cacheable({ maxTtl: 5000 }); + expect(cacheable.maxTtl).toBe(5000); + cacheable.maxTtl = undefined; + expect(cacheable.maxTtl).toBeUndefined(); + }); + + test("should disable maxTtl by setting to 0 or negative", () => { + const cacheable = new Cacheable({ maxTtl: 5000 }); + cacheable.maxTtl = 0; + expect(cacheable.maxTtl).toBeUndefined(); + cacheable.maxTtl = 5000; + cacheable.maxTtl = -1; + expect(cacheable.maxTtl).toBeUndefined(); + }); + + test("should handle negative maxTtl in constructor", () => { + const cacheable = new Cacheable({ maxTtl: -1 }); + expect(cacheable.maxTtl).toBeUndefined(); + }); + + test("should cap per-entry ttl when it exceeds maxTtl", async () => { + const cacheable = new Cacheable({ maxTtl: 100 }); + await cacheable.set("key1", "value1", 5000); + const raw = await cacheable.primary.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeLessThanOrEqual(now + 110); + expect(raw.expires).toBeGreaterThan(now); + }); + + test("should not cap per-entry ttl when within maxTtl", async () => { + const cacheable = new Cacheable({ maxTtl: 10_000 }); + await cacheable.set("key1", "value1", 100); + const raw = await cacheable.primary.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeLessThanOrEqual(now + 110); + expect(raw.expires).toBeGreaterThan(now); + }); + + test("should enforce maxTtl when no ttl is set (indefinite)", async () => { + const cacheable = new Cacheable({ maxTtl: 200 }); + await cacheable.set("key1", "value1"); + const raw = await cacheable.primary.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeLessThanOrEqual(now + 210); + expect(raw.expires).toBeGreaterThan(now); + }); + + test("should enforce maxTtl when default ttl exceeds maxTtl", async () => { + const cacheable = new Cacheable({ ttl: 60_000, maxTtl: 200 }); + await cacheable.set("key1", "value1"); + const raw = await cacheable.primary.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeLessThanOrEqual(now + 210); + expect(raw.expires).toBeGreaterThan(now); + }); + + test("should work with maxTtl as shorthand string", async () => { + const cacheable = new Cacheable({ maxTtl: "1s" }); + await cacheable.set("key1", "value1", "1h"); + const raw = await cacheable.primary.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeLessThanOrEqual(now + 1010); + expect(raw.expires).toBeGreaterThan(now); + }); + + test("should expire items at maxTtl boundary", async () => { + const cacheable = new Cacheable({ maxTtl: 50 }); + await cacheable.set("key1", "value1", 10_000); + const value1 = await cacheable.get("key1"); + expect(value1).toBe("value1"); + await sleep(60); + const value2 = await cacheable.get("key1"); + expect(value2).toBeUndefined(); + }); + + test("should enforce maxTtl on setMany", async () => { + const cacheable = new Cacheable({ maxTtl: 200 }); + await cacheable.setMany([ + { key: "k1", value: "v1", ttl: 60_000 }, + { key: "k2", value: "v2" }, + ]); + const raw1 = await cacheable.primary.getRaw("k1"); + const raw2 = await cacheable.primary.getRaw("k2"); + const now = Date.now(); + expect(raw1).toBeDefined(); + expect(raw1.expires).toBeDefined(); + expect(raw1.expires).toBeLessThanOrEqual(now + 210); + expect(raw2).toBeDefined(); + expect(raw2.expires).toBeDefined(); + expect(raw2.expires).toBeLessThanOrEqual(now + 210); + }); + + test("should enforce maxTtl on secondary store", async () => { + const secondary = new Keyv(); + const cacheable = new Cacheable({ secondary, maxTtl: 200 }); + await cacheable.set("key1", "value1", 60_000); + const raw = await secondary.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeLessThanOrEqual(now + 210); + }); + + test("should enforce maxTtl on setMany with secondary store", async () => { + const secondary = new Keyv(); + const cacheable = new Cacheable({ secondary, maxTtl: 200 }); + await cacheable.setMany([{ key: "k1", value: "v1", ttl: 60_000 }]); + const raw = await secondary.getRaw("k1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeLessThanOrEqual(now + 210); + }); + + test("should not interfere when maxTtl is undefined", async () => { + const cacheable = new Cacheable({ ttl: 5000 }); + await cacheable.set("key1", "value1"); + const raw = await cacheable.primary.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeGreaterThan(now + 4000); + }); +}); diff --git a/packages/memory/README.md b/packages/memory/README.md index 1941aa87..e167177c 100644 --- a/packages/memory/README.md +++ b/packages/memory/README.md @@ -226,9 +226,39 @@ Our goal with `cacheable` and `CacheableMemory` is to provide a high performance As you can see from the benchmarks `CacheableMemory` is on par with other caching engines such as `Map`, `Node Cache`, and `bentocache`. We have also tested it against other LRU caching engines such as `quick-lru` and `lru.min` and it performs well against them too. +## Maximum Time to Live (maxTtl) + +You can set a `maxTtl` option to enforce an upper bound on any TTL in the cache. When `maxTtl` is set: +- Any per-entry TTL that exceeds `maxTtl` will be capped to `maxTtl`. +- Entries with no TTL (that would otherwise live indefinitely) will be capped to `maxTtl`. +- The default TTL is still respected if it is within the `maxTtl` limit. + +This is useful when you want to guarantee that no cache entry lives longer than a certain duration, regardless of what TTL is passed to individual `set()` calls. + +```javascript +import { CacheableMemory } from '@cacheable/memory'; + +// No entry can live longer than 1 hour +const cache = new CacheableMemory({ maxTtl: '1h' }); + +cache.set('key1', 'value1', '2h'); // capped to 1 hour +cache.set('key2', 'value2'); // also capped to 1 hour (would otherwise be indefinite) +cache.set('key3', 'value3', '30m'); // 30 minutes is within maxTtl, so it stays as-is +``` + +You can also set `maxTtl` after construction: + +```javascript +const cache = new CacheableMemory(); +cache.maxTtl = 5000; // 5 seconds max +cache.maxTtl = '10m'; // 10 minutes max +cache.maxTtl = undefined; // disable maxTtl (no upper bound) +``` + ## CacheableMemory Options * `ttl`: The time to live for the cache in milliseconds. Default is `undefined` which is means indefinitely. +* `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Default is `undefined` (no maximum). * `useClones`: If the cache should use clones for the values. Default is `true`. * `lruSize`: The size of the LRU cache. Default is `0`, which disables the LRU cache (no LRU eviction is performed). Maximum is `16,777,216 (2^24)`. * `checkInterval`: The interval to check for expired keys in milliseconds. Default is `0` which is disabled. @@ -252,6 +282,7 @@ As you can see from the benchmarks `CacheableMemory` is on par with other cachin * `wrap(function, WrapSyncOptions)`: Wraps a `sync` function in a cache. * `clear()`: Clears the cache. * `ttl`: The default time to live for the cache in milliseconds. Default is `undefined` which is disabled. +* `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Default is `undefined` (no maximum). * `useClones`: If the cache should use clones for the values. Default is `true`. * `lruSize`: The size of the LRU cache. Default is `0`, which disables the LRU cache (no LRU eviction is performed). Maximum is `16,777,216 (2^24)`. * `size`: The number of keys in the cache. diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 9db7ec01..ac674008 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -38,6 +38,9 @@ export type StoreHashAlgorithmFunction = ( * @property {number|string} [ttl] - Time to Live - If you set a number it is miliseconds, if you set a string it is a human-readable * format such as `1s` for 1 second or `1h` for 1 hour. Setting undefined means that it will use the default time-to-live. If both are * undefined then it will not have a time-to-live. + * @property {number|string} [maxTtl] - Maximum Time to Live - The upper bound for any TTL set on a cache entry. If a TTL (whether from the + * default or per-entry) exceeds this value, the entry's TTL is capped to maxTtl. Can be a number in milliseconds or a human-readable + * format such as `1s`, `1m`, `1h`, `1d`. Default is `undefined` (no maximum). * @property {boolean} [useClone] - If true, it will clone the value before returning it. If false, it will return the value directly. Default is true. * @property {number} [lruSize] - The size of the LRU cache. If set to 0, it will not use LRU cache. Default is 0. If you are using LRU then the limit is based on Map() size 17mm. * @property {number} [checkInterval] - The interval to check for expired items. If set to 0, it will not check for expired items. Default is 0. @@ -45,6 +48,7 @@ export type StoreHashAlgorithmFunction = ( */ export type CacheableMemoryOptions = { ttl?: number | string; + maxTtl?: number | string; useClone?: boolean; lruSize?: number; checkInterval?: number; @@ -73,6 +77,7 @@ export class CacheableMemory extends Hookified { () => new Map(), ); private _ttl: number | string | undefined; // Turned off by default + private _maxTtl: number | string | undefined; // Turned off by default private _useClone = true; // Turned on by default private _lruSize = 0; // Turned off by default private _checkInterval = 0; // Turned off by default @@ -89,6 +94,10 @@ export class CacheableMemory extends Hookified { this.setTtl(options.ttl); } + if (options?.maxTtl !== undefined) { + this.setMaxTtl(options.maxTtl); + } + if (options?.useClone !== undefined) { this._useClone = options.useClone; } @@ -142,6 +151,24 @@ export class CacheableMemory extends Hookified { this.setTtl(value); } + /** + * Gets the maximum time-to-live. When set, any TTL that exceeds this value is capped to maxTtl. + * Entries with no TTL will also be capped to maxTtl. Default is `undefined` (no maximum). + * @returns {number|string|undefined} - The maximum TTL in milliseconds, human-readable format, or undefined. + */ + public get maxTtl(): number | string | undefined { + return this._maxTtl; + } + + /** + * Sets the maximum time-to-live. When set, any TTL that exceeds this value is capped to maxTtl. + * Entries with no TTL will also be capped to maxTtl. + * @param {number|string|undefined} value - The maximum TTL in milliseconds or human-readable format (e.g. '1s', '1h'). If undefined, no maximum is enforced. + */ + public set maxTtl(value: number | string | undefined) { + this.setMaxTtl(value); + } + /** * Gets whether to use clone * @returns {boolean} - If true, it will clone the value before returning it. If false, it will return the value directly. Default is true. @@ -448,6 +475,15 @@ export class CacheableMemory extends Hookified { } } + if (this._maxTtl !== undefined) { + const maxExpires = shorthandToTime(this._maxTtl); + if (expires === undefined) { + expires = maxExpires; + } else if (expires > maxExpires) { + expires = maxExpires; + } + } + if (this._lruSize > 0) { if (store.has(hookItem.key)) { this.lruMoveToFront(hookItem.key); @@ -785,6 +821,16 @@ export class CacheableMemory extends Hookified { } } + private setMaxTtl(maxTtl: number | string | undefined): void { + if (typeof maxTtl === "string" || maxTtl === undefined) { + this._maxTtl = maxTtl; + } else if (maxTtl > 0) { + this._maxTtl = maxTtl; + } else { + this._maxTtl = undefined; + } + } + private hasExpired(item: CacheableStoreItem): boolean { if (item.expires && Date.now() > item.expires) { return true; diff --git a/packages/memory/test/index.test.ts b/packages/memory/test/index.test.ts index 16dca1f3..f4cac40e 100644 --- a/packages/memory/test/index.test.ts +++ b/packages/memory/test/index.test.ts @@ -1080,3 +1080,164 @@ describe("CacheableMemory Hooks", () => { expect(afterGetResult).toBeUndefined(); }); }); + +describe("CacheableMemory maxTtl", () => { + test("should have default maxTtl as undefined", () => { + const cache = new CacheableMemory(); + expect(cache.maxTtl).toBe(undefined); + }); + + test("should set maxTtl via constructor", () => { + const cache = new CacheableMemory({ maxTtl: 5000 }); + expect(cache.maxTtl).toBe(5000); + }); + + test("should set maxTtl via constructor with string", () => { + const cache = new CacheableMemory({ maxTtl: "1h" }); + expect(cache.maxTtl).toBe("1h"); + }); + + test("should set maxTtl via setter", () => { + const cache = new CacheableMemory(); + cache.maxTtl = 10_000; + expect(cache.maxTtl).toBe(10_000); + }); + + test("should set maxTtl via setter with string", () => { + const cache = new CacheableMemory(); + cache.maxTtl = "30m"; + expect(cache.maxTtl).toBe("30m"); + }); + + test("should disable maxTtl by setting to undefined", () => { + const cache = new CacheableMemory({ maxTtl: 5000 }); + expect(cache.maxTtl).toBe(5000); + cache.maxTtl = undefined; + expect(cache.maxTtl).toBe(undefined); + }); + + test("should disable maxTtl by setting to 0 or negative", () => { + const cache = new CacheableMemory({ maxTtl: 5000 }); + cache.maxTtl = 0; + expect(cache.maxTtl).toBe(undefined); + cache.maxTtl = 5000; + cache.maxTtl = -1; + expect(cache.maxTtl).toBe(undefined); + }); + + test("should handle negative maxTtl in constructor", () => { + const cache = new CacheableMemory({ maxTtl: -1 }); + expect(cache.maxTtl).toBe(undefined); + }); + + test("should cap ttl when it exceeds maxTtl", async () => { + const cache = new CacheableMemory({ maxTtl: 50 }); + cache.set("key1", "value1", 200); + const raw = cache.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeDefined(); + const now = Date.now(); + expect(raw?.expires as number).toBeLessThanOrEqual(now + 55); + expect(raw?.expires as number).toBeGreaterThan(now); + }); + + test("should not cap ttl when it is within maxTtl", async () => { + const cache = new CacheableMemory({ maxTtl: 5000 }); + cache.set("key1", "value1", 100); + const raw = cache.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeDefined(); + const now = Date.now(); + expect(raw?.expires as number).toBeLessThanOrEqual(now + 105); + expect(raw?.expires as number).toBeGreaterThan(now); + }); + + test("should enforce maxTtl when no ttl is set on entry or default", async () => { + const cache = new CacheableMemory({ maxTtl: 100 }); + cache.set("key1", "value1"); + const raw = cache.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeDefined(); + const now = Date.now(); + expect(raw?.expires as number).toBeLessThanOrEqual(now + 105); + expect(raw?.expires as number).toBeGreaterThan(now); + }); + + test("should enforce maxTtl when default ttl exceeds maxTtl", async () => { + const cache = new CacheableMemory({ ttl: 5000, maxTtl: 100 }); + cache.set("key1", "value1"); + const raw = cache.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeDefined(); + const now = Date.now(); + expect(raw?.expires as number).toBeLessThanOrEqual(now + 105); + expect(raw?.expires as number).toBeGreaterThan(now); + }); + + test("should work with maxTtl as shorthand string", async () => { + const cache = new CacheableMemory({ maxTtl: "1s" }); + cache.set("key1", "value1", "1h"); + const raw = cache.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeDefined(); + const now = Date.now(); + expect(raw?.expires as number).toBeLessThanOrEqual(now + 1005); + expect(raw?.expires as number).toBeGreaterThan(now); + }); + + test("should expire items at maxTtl boundary", async () => { + const cache = new CacheableMemory({ maxTtl: 30 }); + cache.set("key1", "value1", 5000); + expect(cache.get("key1")).toBe("value1"); + await sleep(40); + expect(cache.get("key1")).toBeUndefined(); + }); + + test("should enforce maxTtl on setMany", async () => { + const cache = new CacheableMemory({ maxTtl: 100 }); + cache.setMany([ + { key: "k1", value: "v1", ttl: 5000 }, + { key: "k2", value: "v2" }, + ]); + const raw1 = cache.getRaw("k1"); + const raw2 = cache.getRaw("k2"); + const now = Date.now(); + expect(raw1).toBeDefined(); + expect(raw1?.expires).toBeDefined(); + expect(raw1?.expires as number).toBeLessThanOrEqual(now + 105); + expect(raw2).toBeDefined(); + expect(raw2?.expires).toBeDefined(); + expect(raw2?.expires as number).toBeLessThanOrEqual(now + 105); + }); + + test("should enforce maxTtl when SetOptions object with ttl is used", () => { + const cache = new CacheableMemory({ maxTtl: 100 }); + cache.set("key1", "value1", { ttl: 5000 }); + const raw = cache.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeDefined(); + const now = Date.now(); + expect(raw?.expires as number).toBeLessThanOrEqual(now + 105); + }); + + test("should enforce maxTtl when SetOptions object with expire is used", () => { + const cache = new CacheableMemory({ maxTtl: 100 }); + const farFutureExpire = Date.now() + 60_000; + cache.set("key1", "value1", { expire: farFutureExpire }); + const raw = cache.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeDefined(); + const now = Date.now(); + expect(raw?.expires as number).toBeLessThanOrEqual(now + 105); + }); + + test("should not interfere when maxTtl is undefined", () => { + const cache = new CacheableMemory({ ttl: 5000 }); + cache.set("key1", "value1"); + const raw = cache.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeDefined(); + const now = Date.now(); + expect(raw?.expires as number).toBeGreaterThan(now + 4000); + }); +}); From d777d3c6208ee6337a3e08683c41dc6888ceade6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 21:28:03 +0000 Subject: [PATCH 2/2] fix(cacheable): re-cap TTL after BEFORE_SET hook to enforce maxTtl The BEFORE_SET hook can mutate item.ttl, which could bypass the maxTtl enforcement. Now the TTL is re-capped after the hook runs, ensuring maxTtl is always enforced regardless of hook modifications. https://claude.ai/code/session_01Gdiqk2rKNc1Vji61zm3qmy --- packages/cacheable/src/index.ts | 1 + packages/cacheable/test/index.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/cacheable/src/index.ts b/packages/cacheable/src/index.ts index a2bfbd19..ebbfc005 100644 --- a/packages/cacheable/src/index.ts +++ b/packages/cacheable/src/index.ts @@ -567,6 +567,7 @@ export class Cacheable extends Hookified { const item = { key, value, ttl: primaryTtl }; await this.hook(CacheableHooks.BEFORE_SET, item); const hookOverridden = item.ttl !== primaryTtl; + item.ttl = this.capTtl(item.ttl, maxTtlMs); const promises = []; promises.push(this._primary.set(item.key, item.value, item.ttl)); if (this._secondary) { diff --git a/packages/cacheable/test/index.test.ts b/packages/cacheable/test/index.test.ts index 3d1b87fd..ee64f500 100644 --- a/packages/cacheable/test/index.test.ts +++ b/packages/cacheable/test/index.test.ts @@ -1979,4 +1979,17 @@ describe("cacheable maxTtl", () => { const now = Date.now(); expect(raw.expires).toBeGreaterThan(now + 4000); }); + + test("should re-cap ttl after BEFORE_SET hook overrides it", async () => { + const cacheable = new Cacheable({ maxTtl: 200 }); + cacheable.onHook(CacheableHooks.BEFORE_SET, (item) => { + item.ttl = 60_000; + }); + await cacheable.set("key1", "value1", 100); + const raw = await cacheable.primary.getRaw("key1"); + expect(raw).toBeDefined(); + expect(raw.expires).toBeDefined(); + const now = Date.now(); + expect(raw.expires).toBeLessThanOrEqual(now + 210); + }); });