{"id":13524832,"url":"https://github.com/mcollina/async-cache-dedupe","last_synced_at":"2025-05-14T04:07:27.435Z","repository":{"id":37893154,"uuid":"385751973","full_name":"mcollina/async-cache-dedupe","owner":"mcollina","description":"Async cache with dedupe support","archived":false,"fork":false,"pushed_at":"2025-05-10T22:13:58.000Z","size":184,"stargazers_count":668,"open_issues_count":8,"forks_count":46,"subscribers_count":12,"default_branch":"main","last_synced_at":"2025-05-10T23:19:31.111Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mcollina.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null},"funding":{"github":["mcollina"]}},"created_at":"2021-07-13T22:38:30.000Z","updated_at":"2025-05-10T22:13:55.000Z","dependencies_parsed_at":"2023-10-02T11:00:14.274Z","dependency_job_id":"250c8ad3-128f-46b5-acce-37885dfee9a0","html_url":"https://github.com/mcollina/async-cache-dedupe","commit_stats":{"total_commits":127,"total_committers":29,"mean_commits":4.379310344827586,"dds":0.6850393700787402,"last_synced_commit":"ad14f205ac2ae7d8356e3a11036aa7694c996d31"},"previous_names":[],"tags_count":27,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcollina%2Fasync-cache-dedupe","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcollina%2Fasync-cache-dedupe/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcollina%2Fasync-cache-dedupe/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mcollina%2Fasync-cache-dedupe/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mcollina","download_url":"https://codeload.github.com/mcollina/async-cache-dedupe/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254069055,"owners_count":22009477,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-08-01T06:01:13.838Z","updated_at":"2025-05-14T04:07:27.395Z","avatar_url":"https://github.com/mcollina.png","language":"JavaScript","funding_links":["https://github.com/sponsors/mcollina"],"categories":["JavaScript"],"sub_categories":[],"readme":"# async-cache-dedupe\n\n`async-cache-dedupe` is a cache for asynchronous fetching of resources\nwith full deduplication, i.e. the same resource is only asked once at any given time.\n\n## Install\n\n```bash\nnpm i async-cache-dedupe\n```\n\n## Example\n\n```js\nimport { createCache } from 'async-cache-dedupe'\n\nconst cache = createCache({\n  ttl: 5, // seconds\n  stale: 5, // number of seconds to return data after ttl has expired\n  storage: { type: 'memory' },\n})\n\ncache.define('fetchSomething', async (k) =\u003e {\n  console.log('query', k)\n  // query 42\n  // query 24\n\n  return { k }\n})\n\nconst p1 = cache.fetchSomething(42)\nconst p2 = cache.fetchSomething(24)\nconst p3 = cache.fetchSomething(42)\n\nconst res = await Promise.all([p1, p2, p3])\n\nconsole.log(res)\n// [\n//   { k: 42 },\n//   { k: 24 }\n//   { k: 42 }\n// ]\n```\n\nCommonjs/`require` is also supported.\n\n## API\n\n### `createCache(opts)`\n\nCreates a new cache.\n\nOptions:\n\n* `ttl`: the maximum time a cache entry can live, default `0`; if `0`, an element is removed from the cache as soon as the promise resolves.\n* `stale`: the time after which the value is served from the cache after the ttl has expired. This can be a number in seconds or a function that accepts the data and returns the stale value.\n* `onDedupe`: a function that is called every time it is defined is deduped.\n* `onError`: a function that is called every time there is a cache error.\n* `onHit`: a function that is called every time there is a hit in the cache.\n* `onMiss`: a function that is called every time the result is not in the cache.\n* `storage`: the storage options; default is `{ type: \"memory\" }`\n  Storage options are:\n  * `type`: `memory` (default) or `redis`\n  * `options`: by storage type\n    * for `memory` type\n      * `size`: maximum number of items to store in the cache _per resolver_. Default is `1024`.\n      * `invalidation`: enable invalidation, see [invalidation](#invalidation). Default is disabled.\n      * `log`: logger instance `pino` compatible, default is disabled.\n\n      Example  \n\n      ```js\n      createCache({ storage: { type: 'memory', options: { size: 2048 } } })\n      ```\n\n    * for `redis` type\n      * `client`: a redis client instance, mandatory. Should be an `ioredis` client or compatible.\n      * `invalidation`: enable invalidation, see [invalidation](#invalidation). Default is disabled.\n      * `invalidation.referencesTTL`: references TTL in seconds, it means how long the references are alive; it should be set at the maximum of all the caches ttl.\n      * `log`: logger instance `pino` compatible, default is disabled.\n\n      Example\n\n      ```js\n      createCache({ storage: { type: 'redis', options: { client: new Redis(), invalidation: { referencesTTL: 60 } } } })\n      ```\n* `transformer`: the transformer to used to serialize and deserialize the cache entries. \n  It must be an object with the following methods:\n  * `serialize`: a function that receives the result of the original function and returns a serializable object.\n  * `deserialize`: a function that receives the serialized object and returns the original result.\n\n  * Default is `undefined`, so the default transformer is used.\n\n    Example\n\n    ```js\n    import superjson from 'superjson';\n\n    const cache = createCache({\n      transformer: {\n        serialize: (result) =\u003e superjson.serialize(result),\n        deserialize: (serialized) =\u003e superjson.deserialize(serialized),\n      }\n    })\n    ```\n\n### `cache.define(name[, opts], original(arg, cacheKey))`\n\nDefine a new function to cache of the given `name`.\n\nThe `define` method adds a `cache[name]` function that will call the `original` function if the result is not present\nin the cache. The cache key for `arg` is computed using [`safe-stable-stringify`](https://www.npmjs.com/package/safe-stable-stringify) and it is passed as the `cacheKey` argument to the original function.\n\nOptions:\n\n* `ttl`: a number or a function that returns a number of the maximum time a cache entry can live, default as defined in the cache; default is zero, so cache is disabled, the function will be only the deduped. The first argument of the function is the result of the original function.\n* `stale`: the time after which the value is served from the cache after the ttl has expired. This can be a number in seconds or a function that accepts the data and returns the stale value.\n* `serialize`: a function to convert the given argument into a serializable object (or string).\n* `onDedupe`: a function that is called every time there is defined is deduped.\n* `onError`: a function that is called every time there is a cache error.\n* `onHit`: a function that is called every time there is a hit in the cache.\n* `onMiss`: a function that is called every time the result is not in the cache.\n* `storage`: the storage to use, same as above. It's possible to specify different storages for each defined function for fine-tuning.\n* `transformer`: the transformer to used to serialize and deserialize the cache entries. It's possible to specify different transformers for each defined function for fine-tuning.\n* `references`: sync or async function to generate references, it receives `(args, key, result)` from the defined function call and must return an array of strings or falsy; see [invalidation](#invalidation) to know how to use them.\n\n  Example 1\n\n  ```js\n    const cache = createCache({ ttl: 60 })\n\n    cache.define('fetchUser', {\n      references: (args, key, result) =\u003e result ? [`user~${result.id}`] : null\n    }, \n    (id) =\u003e database.find({ table: 'users', where: { id }}))\n\n    await cache.fetchUser(1)\n  ```\n\n  Example 2 - dynamically set `ttl` based on result.\n  \n  ```js\n  const cache = createCache()\n\n  cache.define('fetchAccessToken', {\n    ttl: (result) =\u003e result.expiresInSeconds\n  }, async () =\u003e {\n    \n    const response = await fetch(\"https://example.com/token\");\n    const result = await response.json();\n    // =\u003e { \"token\": \"abc\", \"expiresInSeconds\": 60 }\n    \n    return result;\n  })\n\n  await cache.fetchAccessToken()\n  ```\n\n  Example 3 - dynamically set `stale` value based on result.\n\n  ```js\n  const cache = createCache()\n\n  cache.define('fetchUserProfile', {\n    ttl: 60,\n    stale: (result) =\u003e result.staleWhileRevalidateInSeconds\n  }, async () =\u003e {\n    \n    const response = await fetch(\"https://example.com/token\");\n    const result = await response.json();\n    // =\u003e { \"username\": \"MrTest\", \"staleWhileRevalidateInSeconds\": 5 }\n    \n    return result;\n  })\n\n  await cache.fetchUserProfile()\n  ```\n\n### `cache.clear([name], [arg])`\n\nClear the cache. If `name` is specified, all the cache entries from the function defined with that name are cleared.\nIf `arg` is specified, only the elements cached with the given `name` and `arg` are cleared.\n\n### `cache.invalidateAll(references, [storage])`\n\n`cache.invalidateAll` perform invalidation over the whole storage; if `storage` is not specified - using the same `name` as the defined function, invalidation is made over the default storage.\n\n`references` can be:\n\n* a single reference\n* an array of references (without wildcard)\n* a matching reference with wildcard, same logic for `memory` and `redis`\n\nExample\n\n```js\nconst cache = createCache({ ttl: 60 })\n\ncache.define('fetchUser', {\n  references: (args, key, result) =\u003e result ? [`user:${result.id}`] : null\n}, (id) =\u003e database.find({ table: 'users', where: { id }}))\n\ncache.define('fetchCountries', {\n  storage: { type: 'memory', size: 256 },\n  references: (args, key, result) =\u003e [`countries`]\n}, (id) =\u003e database.find({ table: 'countries' }))\n\n// ...\n\n// invalidate all users from default storage\ncache.invalidateAll('user:*')\n\n// invalidate user 1 from default storage\ncache.invalidateAll('user:1')\n\n// invalidate user 1 and user 2 from default storage\ncache.invalidateAll(['user:1', 'user:2'])\n\n// note \"fetchCountries\" uses a different storage\ncache.invalidateAll('countries', 'fetchCountries')\n```\n\nSee below how invalidation and references work.\n\n## Invalidation\n\nAlong with `time to live` invalidation of the cache entries, we can use invalidation by keys.  \nThe concept behind invalidation by keys is that entries have an auxiliary key set that explicitly links requests along with their own result. These auxiliary keys are called here `references`.  \nA scenario. Let's say we have an entry _user_ `{id: 1, name: \"Alice\"}`, it may change often or rarely, the `ttl` system is not accurate:\n\n* it can be updated before `ttl` expiration, in this case the old value is shown until expiration by `ttl`.  \n* it's not been updated during `ttl` expiration, so in this case, we don't need to reload the value, because it's not changed\n\nTo solve this common problem, we can use `references`.  \nWe can say that the result of defined function `getUser(id: 1)` has reference `user~1`, and the result of defined function `findUsers`, containing `{id: 1, name: \"Alice\"},{id: 2, name: \"Bob\"}` has references `[user~1,user~2]`.\nSo we can find the results in the cache by their `references`, independently of the request that generated them, and we can invalidate by `references`.\n\nSo, when a writing event involving `user {id: 1}` happens (usually an update), we can remove all the entries in the cache that have references to `user~1`, so the result of `getUser(id: 1)` and `findUsers`, and they will be reloaded at the next request with the new data - but not the result of `getUser(id: 2)`.\n\nExplicit invalidation is `disabled` by default, you have to enable it in `storage` settings.\n\nSee [mercurius-cache-example](https://github.com/mercurius-js/mercurius-cache-example) for a complete example.\n\n### Redis\n\nUsing a `redis` storage is the best choice for a shared and/or large cache.  \nAll the `references` entries in redis have `referencesTTL`, so they are all cleaned at some time.\n`referencesTTL` value should be set at the maximum of all the `ttl`s, to let them be available for every cache entry, but at the same time, they expire, avoiding data leaking.  \nAnyway, we should keep `references` up-to-date to be more efficient on writes and invalidation, using the `garbage collector` function, that prunes the expired references: while expired references do not compromise the cache integrity, they slow down the I/O operations.  \nStorage `memory` doesn't have `gc`.\n\n### Redis garbage collector\n\nAs said, While the garbage collector is optional, is highly recommended to keep references up to date and improve performances on setting cache entries and invalidation of them.  \n\n### `storage.gc([mode], [options])`\n\n* `mode`: `lazy` (default) or `strict`.\n  In `lazy` mode, only a chunk of the `references` are randomly checked, and probably freed; running `lazy` jobs tend to eventually clear all the expired `references`.\n  In `strict` mode, all the `references` are checked and freed, and after that, `references` and entries are perfectly clean.\n  `lazy` mode is the light heuristic way to ensure cached entries and `references` are cleared without stressing too much `redis`, `strict` mode at the opposite stress more `redis` to get a perfect result.\n  The best strategy is to combine them both, running often `lazy` jobs along with some `strict` ones, depending on the size of the cache.\n\nOptions:\n\n* `chunk`: the chunk size of references analyzed per loops, default `64`\n* `lazy~chunk`: the chunk size of references analyzed per loops in `lazy` mode, default `64`; if both `chunk` and `lazy.chunk` is set, the maximum one is taken\n* `lazy~cursor`: the cursor offset, default zero; cursor should be set at `report.cursor` to continue scanning from the previous operation\n\nReturn `report` of the `gc` job, as follows\n\n```json\n\"report\":{\n  \"references\":{\n      \"scanned\":[\"r:user:8\", \"r:group:11\", \"r:group:16\"],\n      \"removed\":[\"r:user:8\", \"r:group:16\"]\n  },\n  \"keys\":{\n      \"scanned\":[\"users~1\"],\n      \"removed\":[\"users~1\"]\n  },\n  \"loops\":4,\n  \"cursor\":0,\n  \"error\":null\n}\n```\n\nExample\n\n```js\nimport { createCache, createStorage } from 'async-cache-dedupe'\n\nconst cache = createCache({\n  ttl: 5,\n  storage: { type: 'redis', options: { client: redisClient, invalidation: true } },\n})\n// ... cache.define('fetchSomething'\n\nconst storage = createStorage('redis', { client: redisClient, invalidation: true })\n\nlet cursor\nsetInterval(() =\u003e {\n  const report = await storage.gc('lazy', { lazy: { cursor } })\n  if(report.error) {\n    console.error('error on redis gc', error)\n    return\n  }\n  console.log('gc report (lazy)', report)\n  cursor = report.cursor\n}, 60e3).unref()\n\nsetInterval(() =\u003e {\n  const report = await storage.gc('strict', { chunk: 128 })\n  if(report.error) {\n    console.error('error on redis gc', error)\n    return\n  }\n  console.log('gc report (strict)', report)\n}, 10 * 60e3).unref()\n\n```\n\n---\n\n## TypeScript\n\nThis module provides a basic type definition for TypeScript.  \nAs the library does some meta-programming and magic stuff behind the scenes, your compiler could yell at you when defining functions using the `define` property.  \nTo avoid this, chain all defined functions in a single invocation:\n\n```ts\nimport { createCache, Cache } from \"async-cache-dedupe\";\n\nconst fetchSomething = async (k: any) =\u003e {\n  console.log(\"query\", k);\n  return { k };\n};\n\nconst cache = createCache({\n  ttl: 5, // seconds\n  storage: { type: \"memory\" },\n});\n\nconst cacheInstance = cache\n  .define(\"fetchSomething\", fetchSomething)\n  .define(\"fetchSomethingElse\", fetchSomething);\n\nconst p1 = cacheInstance.fetchSomething(42); // \u003c--- TypeScript doesn't argue anymore here!\nconst p2 = cacheInstance.fetchSomethingElse(42); // \u003c--- TypeScript doesn't argue anymore here!\n```\n\n---\n\n## Browser\n\nAll the major browser are supported; only `memory` storage type is supported, `redis` storage can't be used in a browser env.\n\nThis is a very simple example of how to use this module in a browser environment:\n\n```html\n\u003cscript src=\"https://unpkg.com/async-cache-dedupe\"\u003e\u003c/script\u003e\n\n\u003cscript\u003e\n  const cache = asyncCacheDedupe.createCache({\n    ttl: 5, // seconds\n    storage: { type: 'memory' },\n  })\n\n  cache.define('fetchSomething', async (k) =\u003e {\n    console.log('query', k)\n    return { k }\n  })\n\n  const p1 = cache.fetchSomething(42)\n  const p2 = cache.fetchSomething(42)\n  const p3 = cache.fetchSomething(42)\n\n  Promise.all([p1, p2, p3]).then((values) =\u003e {\n    console.log(values)\n  })\n\u003c/script\u003e\n```\n\nYou can also use the module with a bundler. The supported bundlers are `webpack`, `rollup`, `esbuild` and `browserify`.\n\n---\n\n## Maintainers\n\n* [__Matteo Collina__](https://github.com/mcollina), \u003chttps://twitter.com/matteocollina\u003e, \u003chttps://www.npmjs.com/~matteo.collina\u003e\n* [__Simone Sanfratello__](https://github.com/simone-sanfratello), \u003chttps://twitter.com/simonesanfradev\u003e, \u003chttps://www.npmjs.com/~simone.sanfra\u003e\n\n---\n\n## Breaking Changes\n\n* version `0.5.0` -\u003e `0.6.0`\n  * `options.cacheSize` is dropped in favor of `storage`\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmcollina%2Fasync-cache-dedupe","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmcollina%2Fasync-cache-dedupe","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmcollina%2Fasync-cache-dedupe/lists"}