From 003c9947d5d2005a56f78a05b0a86b9da5a4753e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 16:33:57 +0000 Subject: [PATCH 1/5] feat: add Elasticsearch storage adapter Add a new @node-ts-cache/elasticsearch-storage package that provides an Elasticsearch-based storage backend for caching. The implementation uses the @elastic/elasticsearch v8 client and supports: - Basic get/set/clear operations via IAsynchronousCacheType interface - Pre-configured client injection for advanced use cases - Automatic document cleanup with refresh-wait semantics --- pnpm-lock.yaml | 218 +++++++++++++++++- storages/elasticsearch/README.md | 84 +++++++ storages/elasticsearch/package.json | 56 +++++ .../src/elasticsearch.storage.ts | 99 ++++++++ storages/elasticsearch/src/index.ts | 4 + .../test/elasticsearch.storage.test.ts | 99 ++++++++ storages/elasticsearch/tsconfig.json | 7 + 7 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 storages/elasticsearch/README.md create mode 100644 storages/elasticsearch/package.json create mode 100644 storages/elasticsearch/src/elasticsearch.storage.ts create mode 100644 storages/elasticsearch/src/index.ts create mode 100644 storages/elasticsearch/test/elasticsearch.storage.test.ts create mode 100644 storages/elasticsearch/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26ce7b8..c127ab7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,15 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@20.19.30) + storages/elasticsearch: + dependencies: + '@elastic/elasticsearch': + specifier: ^8.17.0 + version: 8.19.1 + '@node-ts-cache/core': + specifier: workspace:* + version: link:../../ts-cache + storages/lru: dependencies: '@node-ts-cache/core': @@ -166,6 +175,14 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@elastic/elasticsearch@8.19.1': + resolution: {integrity: sha512-+1j9NnQVOX+lbWB8LhCM7IkUmjU05Y4+BmSLfusq0msCsQb1Va+OUKFCoOXjCJqQrcgdRdQCjYYyolQ/npQALQ==} + engines: {node: '>=18'} + + '@elastic/transport@8.10.1': + resolution: {integrity: sha512-xo2lPBAJEt81fQRAKa9T/gUq1SPGBHpSnVUXhoSpL996fPZRAfQwFA4BZtEUQL1p8Dezodd3ZN8Wwno+mYyKuw==} + engines: {node: '>=18'} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -521,6 +538,20 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.5.0': + resolution: {integrity: sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.39.0': + resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} + engines: {node: '>=14'} + '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -675,9 +706,18 @@ packages: cpu: [x64] os: [win32] + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/command-line-args@5.2.3': + resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} + + '@types/command-line-usage@5.0.4': + resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -699,6 +739,9 @@ packages: '@types/node@20.19.30': resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + '@types/node@24.10.9': + resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==} + '@typescript-eslint/eslint-plugin@8.53.1': resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -812,12 +855,20 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + apache-arrow@21.1.0: + resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==} + hasBin: true + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-back@6.2.2: + resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} + engines: {node: '>=12.17'} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -855,6 +906,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -885,6 +940,19 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + command-line-args@6.0.1: + resolution: {integrity: sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==} + engines: {node: '>=12.20'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + + command-line-usage@7.0.3: + resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==} + engines: {node: '>=12.20.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1034,6 +1102,15 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-replace@5.0.2: + resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} + engines: {node: '>=14'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1046,6 +1123,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -1093,6 +1173,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hpagent@1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -1159,6 +1243,10 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + json-bignum@0.0.3: + resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} + engines: {node: '>=0.8'} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1186,6 +1274,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -1411,6 +1502,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + secure-json-parse@3.0.2: + resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} + semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -1477,6 +1571,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + table-layout@4.1.1: + resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} + engines: {node: '>=12.17'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -1536,9 +1634,20 @@ packages: engines: {node: '>=14.17'} hasBin: true + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@6.23.0: + resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} + engines: {node: '>=18.17'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1621,6 +1730,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrapjs@5.1.1: + resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} + engines: {node: '>=12.17'} + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -1776,6 +1889,28 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@elastic/elasticsearch@8.19.1': + dependencies: + '@elastic/transport': 8.10.1 + apache-arrow: 21.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + - supports-color + + '@elastic/transport@8.10.1': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + debug: 4.4.3 + hpagent: 1.2.0 + ms: 2.1.3 + secure-json-parse: 3.0.2 + tslib: 2.8.1 + undici: 6.23.0 + transitivePeerDependencies: + - supports-color + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -2028,6 +2163,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.39.0 + + '@opentelemetry/semantic-conventions@1.39.0': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -2129,11 +2273,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.3': optional: true + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true + '@types/command-line-args@5.2.3': {} + + '@types/command-line-usage@5.0.4': {} + '@types/estree@1.0.8': {} '@types/ioredis-mock@8.2.6(ioredis@5.9.2)': @@ -2152,6 +2304,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.10.9': + dependencies: + undici-types: 7.16.0 + '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2304,12 +2460,28 @@ snapshots: dependencies: color-convert: 2.0.1 + apache-arrow@21.1.0: + dependencies: + '@swc/helpers': 0.5.18 + '@types/command-line-args': 5.2.3 + '@types/command-line-usage': 5.0.4 + '@types/node': 24.10.9 + command-line-args: 6.0.1 + command-line-usage: 7.0.3 + flatbuffers: 25.9.23 + json-bignum: 0.0.3 + tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 argparse@2.0.1: {} + array-back@6.2.2: {} + array-union@2.1.0: {} assertion-error@2.0.1: {} @@ -2345,6 +2517,10 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2366,6 +2542,20 @@ snapshots: color-name@1.1.4: {} + command-line-args@6.0.1: + dependencies: + array-back: 6.2.2 + find-replace: 5.0.2 + lodash.camelcase: 4.3.0 + typical: 7.3.0 + + command-line-usage@7.0.3: + dependencies: + array-back: 6.2.2 + chalk-template: 0.4.0 + table-layout: 4.1.1 + typical: 7.3.0 + concat-map@0.0.1: {} cross-spawn@7.0.6: @@ -2541,6 +2731,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-replace@5.0.2: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -2556,6 +2748,8 @@ snapshots: flatted: 3.3.3 keyv: 4.5.4 + flatbuffers@25.9.23: {} + flatted@3.3.3: {} fs-extra@7.0.1: @@ -2604,6 +2798,8 @@ snapshots: has-flag@4.0.0: {} + hpagent@1.2.0: {} + human-id@4.1.3: {} iconv-lite@0.7.2: @@ -2670,6 +2866,8 @@ snapshots: dependencies: argparse: 2.0.1 + json-bignum@0.0.3: {} + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -2697,6 +2895,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} @@ -2906,6 +3106,8 @@ snapshots: safer-buffer@2.1.2: {} + secure-json-parse@3.0.2: {} + semver@7.7.3: {} shebang-command@2.0.0: @@ -2970,6 +3172,11 @@ snapshots: dependencies: has-flag: 4.0.0 + table-layout@4.1.1: + dependencies: + array-back: 6.2.2 + wordwrapjs: 5.1.1 + term-size@2.2.1: {} tinybench@2.9.0: {} @@ -2997,8 +3204,7 @@ snapshots: dependencies: typescript: 5.9.3 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} type-check@0.4.0: dependencies: @@ -3017,8 +3223,14 @@ snapshots: typescript@5.9.3: {} + typical@7.3.0: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} + + undici@6.23.0: {} + universalify@0.1.2: {} uri-js@4.4.1: @@ -3098,6 +3310,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrapjs@5.1.1: {} + yallist@4.0.0: {} yocto-queue@0.1.0: {} diff --git a/storages/elasticsearch/README.md b/storages/elasticsearch/README.md new file mode 100644 index 0000000..900e879 --- /dev/null +++ b/storages/elasticsearch/README.md @@ -0,0 +1,84 @@ +# @node-ts-cache/elasticsearch-storage + +Elasticsearch storage adapter for [node-ts-cache](https://github.com/simllll/node-ts-cache). + +## Installation + +```bash +npm install @node-ts-cache/core @node-ts-cache/elasticsearch-storage +``` + +## Usage + +```typescript +import { Cache, ExpirationStrategy } from '@node-ts-cache/core'; +import { ElasticsearchStorage } from '@node-ts-cache/elasticsearch-storage'; + +const elasticsearchCache = new ExpirationStrategy( + new ElasticsearchStorage({ + indexName: 'my-cache-index', + clientOptions: { + node: 'http://localhost:9200' + } + }) +); + +class MyService { + @Cache(elasticsearchCache, { ttl: 60 }) + async getUsers(): Promise { + // expensive operation + } +} +``` + +## Configuration Options + +```typescript +interface ElasticsearchStorageOptions { + /** The index name to use for storing cache entries */ + indexName: string; + /** Elasticsearch client options */ + clientOptions?: ClientOptions; + /** Pre-configured Elasticsearch client instance (takes precedence over clientOptions) */ + client?: Client; +} +``` + +### Using a pre-configured client + +If you already have an Elasticsearch client instance configured (e.g., with authentication), you can pass it directly: + +```typescript +import { Client } from '@elastic/elasticsearch'; +import { ElasticsearchStorage } from '@node-ts-cache/elasticsearch-storage'; + +const client = new Client({ + node: 'https://my-elasticsearch-cluster.com', + auth: { + apiKey: 'your-api-key' + } +}); + +const storage = new ElasticsearchStorage({ + indexName: 'my-cache', + client +}); +``` + +## Running Tests Locally + +Start Elasticsearch using Docker: + +```bash +docker run -p 9200:9200 -e "discovery.type=single-node" -e "xpack.security.enabled=false" elasticsearch:8.12.0 +``` + +Then run the tests: + +```bash +npm test +``` + +## License + +MIT diff --git a/storages/elasticsearch/package.json b/storages/elasticsearch/package.json new file mode 100644 index 0000000..0df5186 --- /dev/null +++ b/storages/elasticsearch/package.json @@ -0,0 +1,56 @@ +{ + "name": "@node-ts-cache/elasticsearch-storage", + "version": "1.0.0", + "description": "Elasticsearch storage adapter for node-ts-cache", + "keywords": [ + "node", + "nodejs", + "cache", + "typescript", + "ts", + "caching", + "elasticsearch", + "elastic", + "ts-cache" + ], + "homepage": "https://github.com/simllll/node-ts-cache/tree/master/storages/elasticsearch#readme", + "bugs": { + "url": "https://github.com/simllll/node-ts-cache/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/simllll/node-ts-cache.git" + }, + "license": "MIT", + "author": "Simon Tretter ", + "contributors": [ + "Himmet Avsar " + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p .", + "clean": "git clean -fdx src", + "dev": "tsc -p . -w", + "prepublishOnly": "tsc -p .", + "test": "vitest run" + }, + "dependencies": { + "@node-ts-cache/core": "workspace:*", + "@elastic/elasticsearch": "^8.17.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/storages/elasticsearch/src/elasticsearch.storage.ts b/storages/elasticsearch/src/elasticsearch.storage.ts new file mode 100644 index 0000000..d54c8cd --- /dev/null +++ b/storages/elasticsearch/src/elasticsearch.storage.ts @@ -0,0 +1,99 @@ +import { IAsynchronousCacheType } from '@node-ts-cache/core'; +import { Client, ClientOptions } from '@elastic/elasticsearch'; + +export interface ElasticsearchStorageOptions { + /** The index name to use for storing cache entries */ + indexName: string; + /** Elasticsearch client options */ + clientOptions?: ClientOptions; + /** Pre-configured Elasticsearch client instance (takes precedence over clientOptions) */ + client?: Client; +} + +export class ElasticsearchStorage implements IAsynchronousCacheType { + private client: Client; + private indexName: string; + + constructor(options: ElasticsearchStorageOptions) { + this.indexName = options.indexName; + + if (options.client) { + this.client = options.client; + } else if (options.clientOptions) { + this.client = new Client(options.clientOptions); + } else { + this.client = new Client({ node: 'http://localhost:9200' }); + } + } + + public async getItem(key: string): Promise { + try { + const response = await this.client.get({ + index: this.indexName, + id: key + }); + + const source = response._source as { content: unknown } | undefined; + if (source === undefined || source === null) { + return undefined; + } + + return source.content as T; + } catch (error: unknown) { + // Handle 404 (document not found) gracefully + if (this.isNotFoundError(error)) { + return undefined; + } + throw error; + } + } + + public async setItem(key: string, content: T | undefined): Promise { + if (content === undefined) { + try { + await this.client.delete({ + index: this.indexName, + id: key, + refresh: 'wait_for' + }); + } catch (error: unknown) { + // Ignore 404 errors when deleting non-existent documents + if (!this.isNotFoundError(error)) { + throw error; + } + } + return; + } + + await this.client.index({ + index: this.indexName, + id: key, + refresh: 'wait_for', + document: { content } + }); + } + + public async clear(): Promise { + try { + await this.client.indices.delete({ + index: this.indexName, + ignore_unavailable: true + }); + } catch { + // Ignore errors when clearing (index might not exist) + } + } + + public async close(): Promise { + await this.client.close(); + } + + private isNotFoundError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'meta' in error && + (error as { meta?: { statusCode?: number } }).meta?.statusCode === 404 + ); + } +} diff --git a/storages/elasticsearch/src/index.ts b/storages/elasticsearch/src/index.ts new file mode 100644 index 0000000..e8cec7d --- /dev/null +++ b/storages/elasticsearch/src/index.ts @@ -0,0 +1,4 @@ +import { ElasticsearchStorage } from './elasticsearch.storage.js'; + +export default ElasticsearchStorage; +export { ElasticsearchStorage, ElasticsearchStorageOptions } from './elasticsearch.storage.js'; diff --git a/storages/elasticsearch/test/elasticsearch.storage.test.ts b/storages/elasticsearch/test/elasticsearch.storage.test.ts new file mode 100644 index 0000000..c19fb40 --- /dev/null +++ b/storages/elasticsearch/test/elasticsearch.storage.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ElasticsearchStorage } from '../src/elasticsearch.storage.js'; + +// Requires Elasticsearch - use ELASTICSEARCH_NODE env var or defaults to localhost:9200 +// In CI: provided by Elasticsearch service container +// Locally: docker run -p 9200:9200 -e "discovery.type=single-node" -e "xpack.security.enabled=false" elasticsearch:8.12.0 +const node = process.env.ELASTICSEARCH_NODE || 'http://localhost:9200'; +const indexName = 'node-ts-cache-test'; + +let storage: ElasticsearchStorage; + +describe('ElasticsearchStorage', () => { + beforeAll(async () => { + storage = new ElasticsearchStorage({ + indexName, + clientOptions: { node } + }); + // Clear any existing test data + await storage.clear(); + }, 30000); + + afterAll(async () => { + if (storage) { + await storage.clear(); + await storage.close(); + } + }, 10000); + + it('Should clear Elasticsearch index without errors', async () => { + await storage.clear(); + }); + + it('Should return undefined if cache not hit', async () => { + const item = await storage.getItem('nonexistent-key'); + expect(item).toBe(undefined); + }); + + it('Should set and get a string value', async () => { + await storage.setItem('testKey', 'testValue'); + const result = await storage.getItem('testKey'); + expect(result).toBe('testValue'); + }); + + it('Should set and get an object value', async () => { + const testObj = { name: 'test', value: 123 }; + await storage.setItem('objectKey', testObj); + const result = await storage.getItem('objectKey'); + expect(result).toEqual(testObj); + }); + + it('Should delete cache item when set to undefined', async () => { + await storage.setItem('deleteKey', 'value'); + expect(await storage.getItem('deleteKey')).toBe('value'); + + await storage.setItem('deleteKey', undefined); + expect(await storage.getItem('deleteKey')).toBe(undefined); + }); + + it('Should handle numeric values', async () => { + await storage.setItem('numKey', 42); + const result = await storage.getItem('numKey'); + expect(result).toBe(42); + }); + + it('Should handle array values', async () => { + const testArray = [1, 2, 3, 'test']; + await storage.setItem('arrayKey', testArray); + const result = await storage.getItem<(number | string)[]>('arrayKey'); + expect(result).toEqual(testArray); + }); + + it('Should handle nested object values', async () => { + const nested = { + level1: { + level2: { + value: 'deep' + } + } + }; + await storage.setItem('nestedKey', nested); + const result = await storage.getItem('nestedKey'); + expect(result).toEqual(nested); + }); + + it('Should clear all items', async () => { + await storage.setItem('key1', 'value1'); + await storage.setItem('key2', 'value2'); + + await storage.clear(); + + expect(await storage.getItem('key1')).toBe(undefined); + expect(await storage.getItem('key2')).toBe(undefined); + }); + + it('Should handle deleting non-existent keys gracefully', async () => { + // Should not throw + await storage.setItem('never-existed', undefined); + }); +}); diff --git a/storages/elasticsearch/tsconfig.json b/storages/elasticsearch/tsconfig.json new file mode 100644 index 0000000..ef88f77 --- /dev/null +++ b/storages/elasticsearch/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] +} From da586fee16ec3e313120093b6f4eddef36febe3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 17:31:59 +0000 Subject: [PATCH 2/5] feat: add Memcached storage adapter and update documentation Add a new @node-ts-cache/memcached-storage package that provides a Memcached-based storage backend for high-performance distributed caching. The implementation uses the memcached package and supports: - Basic get/set/clear operations via IAsynchronousCacheType interface - Single server or multi-server distributed configuration - Configurable connection options (retries, timeout, poolSize) Also updates all documentation to include both Elasticsearch and Memcached storage adapters in: - Main README.md (packages table, architecture diagram, choosing guide) - ts-cache/README.md (storage engines table) - ts-cache/ADVANCED.md (detailed configuration examples) --- README.md | 46 +++++----- pnpm-lock.yaml | 58 +++++++++++++ storages/memcached/README.md | 86 +++++++++++++++++++ storages/memcached/package.json | 51 +++++++++++ storages/memcached/src/index.ts | 1 + storages/memcached/src/memcached.storage.ts | 83 ++++++++++++++++++ .../memcached/test/memcached.storage.test.ts | 59 +++++++++++++ storages/memcached/tsconfig.json | 7 ++ ts-cache/ADVANCED.md | 81 +++++++++++++++++ ts-cache/README.md | 20 +++-- 10 files changed, 462 insertions(+), 30 deletions(-) create mode 100644 storages/memcached/README.md create mode 100644 storages/memcached/package.json create mode 100644 storages/memcached/src/index.ts create mode 100644 storages/memcached/src/memcached.storage.ts create mode 100644 storages/memcached/test/memcached.storage.test.ts create mode 100644 storages/memcached/tsconfig.json diff --git a/README.md b/README.md index 8256350..fb3870f 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,15 @@ This is a monorepo containing the following packages: ### Storage Adapters -| Package | Version | Description | -| ---------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------ | -| [@node-ts-cache/redis-storage](./storages/redis) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/redis-storage.svg) | Redis storage using `redis` package (v4.x) | -| [@node-ts-cache/ioredis-storage](./storages/redisio) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/ioredis-storage.svg) | Redis storage using `ioredis` with compression support | -| [@node-ts-cache/node-cache-storage](./storages/node-cache) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/node-cache-storage.svg) | In-memory cache using `node-cache` | -| [@node-ts-cache/lru-storage](./storages/lru) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/lru-storage.svg) | LRU cache with automatic eviction | -| [@node-ts-cache/lru-redis-storage](./storages/lru-redis) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/lru-redis-storage.svg) | Two-tier caching (local LRU + remote Redis) | +| Package | Version | Description | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------ | +| [@node-ts-cache/redis-storage](./storages/redis) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/redis-storage.svg) | Redis storage using `redis` package (v4.x) | +| [@node-ts-cache/ioredis-storage](./storages/redisio) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/ioredis-storage.svg) | Redis storage using `ioredis` with compression support | +| [@node-ts-cache/node-cache-storage](./storages/node-cache) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/node-cache-storage.svg) | In-memory cache using `node-cache` | +| [@node-ts-cache/lru-storage](./storages/lru) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/lru-storage.svg) | LRU cache with automatic eviction | +| [@node-ts-cache/lru-redis-storage](./storages/lru-redis) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/lru-redis-storage.svg) | Two-tier caching (local LRU + remote Redis) | +| [@node-ts-cache/elasticsearch-storage](./storages/elasticsearch) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/elasticsearch-storage.svg) | Elasticsearch storage for search-optimized caching | +| [@node-ts-cache/memcached-storage](./storages/memcached) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/memcached-storage.svg) | Memcached storage for distributed caching | ## Documentation @@ -80,25 +82,27 @@ For detailed documentation, see the [main package README](./ts-cache/README.md). │ └───────┬────────┘ │ ├──────────────────────────┼──────────────────────────────────────┤ │ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Storage Layer │ │ -│ ├──────────┬──────────┬──────────┬──────────┬─────────────┤ │ -│ │ Memory │ FS │ Redis │ LRU │ LRU+Redis │ │ -│ └──────────┴──────────┴──────────┴──────────┴─────────────┘ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ Storage Layer │ │ +│ ├────────┬──────┬───────┬─────┬───────────┬───────────────┬────────────┤ │ +│ │ Memory │ FS │ Redis │ LRU │ LRU+Redis │ Elasticsearch │ Memcached │ │ +│ └────────┴──────┴───────┴─────┴───────────┴───────────────┴────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ## Choosing a Storage -| Storage | Type | Use Case | Features | -| ----------------------- | ----- | ---------------------------- | ----------------------------- | -| **MemoryStorage** | Sync | Development, small datasets | Zero config, bundled | -| **FsJsonStorage** | Async | Persistent local cache | File-based, survives restarts | -| **NodeCacheStorage** | Sync | Production single-instance | TTL support, multi-ops | -| **LRUStorage** | Sync | Memory-constrained apps | Auto-eviction, size limits | -| **RedisStorage** | Async | Distributed systems | Shared cache, redis v4 | -| **RedisIOStorage** | Async | Distributed systems | Compression, modern ioredis | -| **LRUWithRedisStorage** | Async | High-performance distributed | Local + remote tiers | +| Storage | Type | Use Case | Features | +| ------------------------ | ----- | ---------------------------- | ------------------------------ | +| **MemoryStorage** | Sync | Development, small datasets | Zero config, bundled | +| **FsJsonStorage** | Async | Persistent local cache | File-based, survives restarts | +| **NodeCacheStorage** | Sync | Production single-instance | TTL support, multi-ops | +| **LRUStorage** | Sync | Memory-constrained apps | Auto-eviction, size limits | +| **RedisStorage** | Async | Distributed systems | Shared cache, redis v4 | +| **RedisIOStorage** | Async | Distributed systems | Compression, modern ioredis | +| **LRUWithRedisStorage** | Async | High-performance distributed | Local + remote tiers | +| **ElasticsearchStorage** | Async | Search-integrated caching | Full-text search, scalable | +| **MemcachedStorage** | Async | High-performance distributed | Simple, fast, widely supported | ## Requirements diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c127ab7..d158abc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,19 @@ importers: specifier: ^8.9.0 version: 8.13.1(@types/ioredis-mock@8.2.6(ioredis@5.9.2))(ioredis@5.9.2) + storages/memcached: + dependencies: + '@node-ts-cache/core': + specifier: workspace:* + version: link:../../ts-cache + memcached: + specifier: ^2.2.2 + version: 2.2.2 + devDependencies: + '@types/memcached': + specifier: ^2.2.10 + version: 2.2.10 + storages/node-cache: dependencies: node-cache: @@ -729,6 +742,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + '@types/node-cache@4.2.5': resolution: {integrity: sha512-faK2Owokboz53g8ooq2dw3iDJ6/HMTCIa2RvMte5WMTiABy+wA558K+iuyRtlR67Un5q9gEKysSDtqZYbSa0Pg==} deprecated: This is a stub types definition. node-cache provides its own type definitions, so you do not need this installed. @@ -956,6 +972,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + connection-parse@0.0.7: + resolution: {integrity: sha512-bTTG28diWg7R7/+qE5NZumwPbCiJOT8uPdZYu674brDjBWQctbaQbYlDKhalS+4i5HxIx+G8dZsnBHKzWpp01A==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1173,6 +1192,9 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hashring@3.2.0: + resolution: {integrity: sha512-xCMovURClsQZ+TR30icCZj+34Fq1hs0y6YCASD6ZqdRfYRybb5Iadws2WS+w09mGM/kf9xyA5FCdJQGcgcraSA==} + hpagent@1.2.0: resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} engines: {node: '>=14'} @@ -1235,6 +1257,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackpot@0.0.6: + resolution: {integrity: sha512-rbWXX+A9ooq03/dfavLg9OXQ8YB57Wa7PY5c4LfU3CgFpwEhhl3WyXTQVurkaT7zBM5I9SSOaiLyJ4I0DQmC0g==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -1302,6 +1327,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + memcached@2.2.2: + resolution: {integrity: sha512-lHwUmqkT9WdUUgRsAvquO4xsKXYaBd644Orz31tuth+w/BIfFNuJMWwsG7sa7H3XXytaNfPTZ5R/yOG3d9zJMA==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1482,6 +1510,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + retry@0.6.0: + resolution: {integrity: sha512-RgncoxLF1GqwAzTZs/K2YpZkWrdIYbXsmesdomi+iPilSzjUyr/wzNIuteoTVaWokzdwZIJ9NHRNQa/RUiOB2g==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1525,6 +1556,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-lru-cache@0.0.2: + resolution: {integrity: sha512-uEv/AFO0ADI7d99OHDmh1QfYzQk/izT1vCmu/riQfh7qjBVUUgRT87E5s5h7CxWCA/+YoZerykpEthzVrW3LIw==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -2294,6 +2328,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/memcached@2.2.10': + dependencies: + '@types/node': 20.19.30 + '@types/node-cache@4.2.5': dependencies: node-cache: 5.1.2 @@ -2558,6 +2596,8 @@ snapshots: concat-map@0.0.1: {} + connection-parse@0.0.7: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2798,6 +2838,11 @@ snapshots: has-flag@4.0.0: {} + hashring@3.2.0: + dependencies: + connection-parse: 0.0.7 + simple-lru-cache: 0.0.2 + hpagent@1.2.0: {} human-id@4.1.3: {} @@ -2857,6 +2902,10 @@ snapshots: isexe@2.0.0: {} + jackpot@0.0.6: + dependencies: + retry: 0.6.0 + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -2915,6 +2964,11 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + memcached@2.2.2: + dependencies: + hashring: 3.2.0 + jackpot: 0.0.6 + merge2@1.4.1: {} micromatch@4.0.8: @@ -3062,6 +3116,8 @@ snapshots: resolve-from@5.0.0: {} + retry@0.6.0: {} + reusify@1.1.0: {} rimraf@6.1.2: @@ -3120,6 +3176,8 @@ snapshots: signal-exit@4.1.0: {} + simple-lru-cache@0.0.2: {} + slash@3.0.0: {} snappy@7.3.3: diff --git a/storages/memcached/README.md b/storages/memcached/README.md new file mode 100644 index 0000000..8d0135f --- /dev/null +++ b/storages/memcached/README.md @@ -0,0 +1,86 @@ +# @node-ts-cache/memcached-storage + +Memcached storage adapter for [node-ts-cache](https://github.com/simllll/node-ts-cache). + +## Installation + +```bash +npm install @node-ts-cache/core @node-ts-cache/memcached-storage +``` + +## Usage + +```typescript +import { Cache, ExpirationStrategy } from '@node-ts-cache/core'; +import { MemcachedStorage } from '@node-ts-cache/memcached-storage'; + +const memcachedCache = new ExpirationStrategy( + new MemcachedStorage({ + location: 'localhost:11211' + }) +); + +class MyService { + @Cache(memcachedCache, { ttl: 60 }) + async getUsers(): Promise { + // expensive operation + } +} +``` + +## Configuration Options + +```typescript +interface MemcachedStorageOptions { + /** Memcached server location(s) - e.g., 'localhost:11211' or ['server1:11211', 'server2:11211'] */ + location: Memcached.Location; + /** Memcached client options */ + options?: Memcached.options; +} +``` + +### Multiple Servers (Distributed) + +Memcached supports distributed caching across multiple servers: + +```typescript +import { MemcachedStorage } from '@node-ts-cache/memcached-storage'; + +const storage = new MemcachedStorage({ + location: ['server1:11211', 'server2:11211', 'server3:11211'], + options: { + retries: 3, + timeout: 5000, + poolSize: 10 + } +}); +``` + +### Available Options + +The `options` parameter accepts all standard [memcached](https://www.npmjs.com/package/memcached) options: + +- `maxKeySize` - Maximum key size (default: 250) +- `maxValue` - Maximum value size (default: 1048576) +- `poolSize` - Connection pool size (default: 10) +- `retries` - Number of retries for failed operations (default: 5) +- `timeout` - Operation timeout in milliseconds (default: 5000) +- `idle` - Idle timeout for connections (default: 5000) + +## Running Tests Locally + +Start Memcached using Docker: + +```bash +docker run -p 11211:11211 memcached:latest +``` + +Then run the tests: + +```bash +npm test +``` + +## License + +MIT diff --git a/storages/memcached/package.json b/storages/memcached/package.json new file mode 100644 index 0000000..f8a978d --- /dev/null +++ b/storages/memcached/package.json @@ -0,0 +1,51 @@ +{ + "name": "@node-ts-cache/memcached-storage", + "version": "1.0.0", + "description": "Memcached storage adapter for node-ts-cache", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p .", + "clean": "git clean -fdx src", + "dev": "tsc -p . -w", + "prepublishOnly": "tsc -p .", + "test": "vitest run" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/simllll/node-ts-cache.git" + }, + "keywords": [ + "typescript", + "cache", + "memcached", + "storage" + ], + "author": "simllll", + "license": "MIT", + "bugs": { + "url": "https://github.com/simllll/node-ts-cache/issues" + }, + "homepage": "https://github.com/simllll/node-ts-cache#readme", + "dependencies": { + "@node-ts-cache/core": "workspace:*", + "memcached": "^2.2.2" + }, + "devDependencies": { + "@types/memcached": "^2.2.10" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/storages/memcached/src/index.ts b/storages/memcached/src/index.ts new file mode 100644 index 0000000..ff90cf1 --- /dev/null +++ b/storages/memcached/src/index.ts @@ -0,0 +1 @@ +export { MemcachedStorage, MemcachedStorageOptions } from './memcached.storage.js'; diff --git a/storages/memcached/src/memcached.storage.ts b/storages/memcached/src/memcached.storage.ts new file mode 100644 index 0000000..ff4015f --- /dev/null +++ b/storages/memcached/src/memcached.storage.ts @@ -0,0 +1,83 @@ +import { IAsynchronousCacheType } from '@node-ts-cache/core'; +import Memcached from 'memcached'; + +export interface MemcachedStorageOptions { + /** Memcached server location(s) - e.g., 'localhost:11211' or ['server1:11211', 'server2:11211'] */ + location: Memcached.Location; + /** Memcached client options */ + options?: Memcached.options; +} + +export class MemcachedStorage implements IAsynchronousCacheType { + private client: Memcached; + + constructor(options: MemcachedStorageOptions) { + this.client = new Memcached(options.location, options.options); + } + + public async getItem(key: string): Promise { + return new Promise((resolve, reject) => { + this.client.get(key, (err, data) => { + if (err) { + reject(err); + return; + } + + if (data === undefined) { + resolve(undefined); + return; + } + + try { + const parsed = JSON.parse(data as string) as T; + resolve(parsed); + } catch { + // If parsing fails, return the raw data + resolve(data as T); + } + }); + }); + } + + public async setItem(key: string, content: T | undefined): Promise { + return new Promise((resolve, reject) => { + if (content === undefined) { + this.client.del(key, err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + return; + } + + const serialized = typeof content === 'string' ? content : JSON.stringify(content); + + // Default TTL of 0 means no expiration in memcached + this.client.set(key, serialized, 0, err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + } + + public async clear(): Promise { + return new Promise((resolve, reject) => { + this.client.flush(err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + } + + public end(): void { + this.client.end(); + } +} diff --git a/storages/memcached/test/memcached.storage.test.ts b/storages/memcached/test/memcached.storage.test.ts new file mode 100644 index 0000000..120fba6 --- /dev/null +++ b/storages/memcached/test/memcached.storage.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { MemcachedStorage } from '../src/index.js'; + +describe('MemcachedStorage', () => { + let storage: MemcachedStorage; + + // These tests require a running Memcached instance + // Skip in CI unless Memcached is available + const memcachedHost = process.env.MEMCACHED_HOST || 'localhost'; + const memcachedPort = process.env.MEMCACHED_PORT || '11211'; + + beforeAll(() => { + storage = new MemcachedStorage({ + location: `${memcachedHost}:${memcachedPort}` + }); + }, 10000); + + afterAll(() => { + if (storage) { + storage.end(); + } + }, 10000); + + it('Should return undefined if cache not hit', async () => { + const item = await storage.getItem('nonexistent-key-' + Date.now()); + expect(item).toBe(undefined); + }); + + it('Should set and get a string value', async () => { + const key = 'test-string-' + Date.now(); + await storage.setItem(key, 'testValue'); + const result = await storage.getItem(key); + expect(result).toBe('testValue'); + }); + + it('Should set and get an object value', async () => { + const key = 'test-object-' + Date.now(); + const content = { data: { name: 'test', value: 123 } }; + await storage.setItem(key, content); + const result = await storage.getItem(key); + expect(result).toEqual(content); + }); + + it('Should delete cache item if set to undefined', async () => { + const key = 'test-delete-' + Date.now(); + await storage.setItem(key, 'value'); + await storage.setItem(key, undefined); + const result = await storage.getItem(key); + expect(result).toBe(undefined); + }); + + it('Should clear all items', async () => { + const key = 'test-clear-' + Date.now(); + await storage.setItem(key, 'value'); + await storage.clear(); + const result = await storage.getItem(key); + expect(result).toBe(undefined); + }); +}); diff --git a/storages/memcached/tsconfig.json b/storages/memcached/tsconfig.json new file mode 100644 index 0000000..ef88f77 --- /dev/null +++ b/storages/memcached/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/ts-cache/ADVANCED.md b/ts-cache/ADVANCED.md index 4a02580..d9d3060 100644 --- a/ts-cache/ADVANCED.md +++ b/ts-cache/ADVANCED.md @@ -298,6 +298,87 @@ const strategy = new ExpirationStrategy(storage); - Reduces Redis round-trips - Good for high-traffic applications +### ElasticsearchStorage + +Elasticsearch-based storage for search-optimized caching. + +```bash +npm install @node-ts-cache/elasticsearch-storage +``` + +```typescript +import { ExpirationStrategy } from '@node-ts-cache/core'; +import { ElasticsearchStorage } from '@node-ts-cache/elasticsearch-storage'; + +// Basic usage +const storage = new ElasticsearchStorage({ + indexName: 'my-cache', + clientOptions: { + node: 'http://localhost:9200' + } +}); + +// With pre-configured client +import { Client } from '@elastic/elasticsearch'; + +const client = new Client({ + node: 'https://my-cluster.com', + auth: { apiKey: 'your-api-key' } +}); + +const storage = new ElasticsearchStorage({ + indexName: 'my-cache', + client +}); + +const strategy = new ExpirationStrategy(storage); +``` + +**Characteristics:** + +- Asynchronous operations +- Scalable distributed storage +- Useful when Elasticsearch is already part of the stack +- Supports complex document caching + +### MemcachedStorage + +High-performance distributed caching using Memcached. + +```bash +npm install @node-ts-cache/memcached-storage +``` + +```typescript +import { ExpirationStrategy } from '@node-ts-cache/core'; +import { MemcachedStorage } from '@node-ts-cache/memcached-storage'; + +// Single server +const storage = new MemcachedStorage({ + location: 'localhost:11211' +}); + +// Multiple servers (distributed) +const distributedStorage = new MemcachedStorage({ + location: ['server1:11211', 'server2:11211', 'server3:11211'], + options: { + retries: 3, + timeout: 5000, + poolSize: 10 + } +}); + +const strategy = new ExpirationStrategy(storage); +``` + +**Characteristics:** + +- Asynchronous operations +- Simple and fast key-value storage +- Excellent for distributed caching +- Lower memory overhead than Redis +- No persistence (data lost on restart) + ## @MultiCache Details ### Signature diff --git a/ts-cache/README.md b/ts-cache/README.md index e6a2023..177f405 100644 --- a/ts-cache/README.md +++ b/ts-cache/README.md @@ -31,15 +31,17 @@ class UserService { The core package includes `MemoryStorage` and `FsJsonStorage`. Additional storage backends are available as separate packages: -| Package | Storage Type | Sync/Async | Use Case | -| ----------------------------------- | ------------------------------------------------------ | ---------- | -------------------------------------- | -| `@node-ts-cache/core` | MemoryStorage | Sync | Development, simple caching | -| `@node-ts-cache/core` | FsJsonStorage | Async | Persistent local cache | -| `@node-ts-cache/node-cache-storage` | [node-cache](https://www.npmjs.com/package/node-cache) | Sync | Production single-instance with TTL | -| `@node-ts-cache/lru-storage` | [lru-cache](https://www.npmjs.com/package/lru-cache) | Sync | Memory-bounded with automatic eviction | -| `@node-ts-cache/redis-storage` | [redis](https://www.npmjs.com/package/redis) (v4.x) | Async | Shared cache | -| `@node-ts-cache/ioredis-storage` | [ioredis](https://www.npmjs.com/package/ioredis) | Async | Shared cache with compression | -| `@node-ts-cache/lru-redis-storage` | LRU + Redis | Async | Two-tier: fast local + shared remote | +| Package | Storage Type | Sync/Async | Use Case | +| -------------------------------------- | --------------------------------------------------------------------- | ---------- | -------------------------------------- | +| `@node-ts-cache/core` | MemoryStorage | Sync | Development, simple caching | +| `@node-ts-cache/core` | FsJsonStorage | Async | Persistent local cache | +| `@node-ts-cache/node-cache-storage` | [node-cache](https://www.npmjs.com/package/node-cache) | Sync | Production single-instance with TTL | +| `@node-ts-cache/lru-storage` | [lru-cache](https://www.npmjs.com/package/lru-cache) | Sync | Memory-bounded with automatic eviction | +| `@node-ts-cache/redis-storage` | [redis](https://www.npmjs.com/package/redis) (v4.x) | Async | Shared cache | +| `@node-ts-cache/ioredis-storage` | [ioredis](https://www.npmjs.com/package/ioredis) | Async | Shared cache with compression | +| `@node-ts-cache/lru-redis-storage` | LRU + Redis | Async | Two-tier: fast local + shared remote | +| `@node-ts-cache/elasticsearch-storage` | [elasticsearch](https://www.npmjs.com/package/@elastic/elasticsearch) | Async | Search-integrated caching | +| `@node-ts-cache/memcached-storage` | [memcached](https://www.npmjs.com/package/memcached) | Async | High-performance distributed cache | ## Decorators From 43eb676743d5def2643d8834051463551527f4bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 21:16:03 +0000 Subject: [PATCH 3/5] feat: add Valkey storage adapter and update documentation Add a new @node-ts-cache/valkey-storage package that provides a Valkey-based storage backend. Valkey is the open-source, Redis-compatible fork backed by the Linux Foundation. Features: - Basic get/set/clear operations via IAsynchronousCacheType interface - Batch operations via IMultiIAsynchronousCacheType interface - Configurable TTL with maxAge option - Error handler support for non-blocking writes - Uses iovalkey client (ioredis-compatible) Also updates all documentation to include Valkey storage adapter in: - Main README.md (packages table, architecture diagram, choosing guide) - ts-cache/README.md (storage engines table) - ts-cache/ADVANCED.md (detailed configuration examples) --- README.md | 12 +- pnpm-lock.yaml | 31 +++++ storages/valkey/README.md | 105 +++++++++++++++++ storages/valkey/package.json | 47 ++++++++ storages/valkey/src/index.ts | 1 + storages/valkey/src/valkey.storage.ts | 123 ++++++++++++++++++++ storages/valkey/test/valkey.storage.test.ts | 77 ++++++++++++ storages/valkey/tsconfig.json | 7 ++ ts-cache/ADVANCED.md | 44 +++++++ ts-cache/README.md | 1 + 10 files changed, 443 insertions(+), 5 deletions(-) create mode 100644 storages/valkey/README.md create mode 100644 storages/valkey/package.json create mode 100644 storages/valkey/src/index.ts create mode 100644 storages/valkey/src/valkey.storage.ts create mode 100644 storages/valkey/test/valkey.storage.test.ts create mode 100644 storages/valkey/tsconfig.json diff --git a/README.md b/README.md index fb3870f..5a17bf2 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ This is a monorepo containing the following packages: | [@node-ts-cache/lru-redis-storage](./storages/lru-redis) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/lru-redis-storage.svg) | Two-tier caching (local LRU + remote Redis) | | [@node-ts-cache/elasticsearch-storage](./storages/elasticsearch) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/elasticsearch-storage.svg) | Elasticsearch storage for search-optimized caching | | [@node-ts-cache/memcached-storage](./storages/memcached) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/memcached-storage.svg) | Memcached storage for distributed caching | +| [@node-ts-cache/valkey-storage](./storages/valkey) | ![npm](https://img.shields.io/npm/v/@node-ts-cache/valkey-storage.svg) | Valkey storage (Redis-compatible, open source) | ## Documentation @@ -82,11 +83,11 @@ For detailed documentation, see the [main package README](./ts-cache/README.md). │ └───────┬────────┘ │ ├──────────────────────────┼──────────────────────────────────────┤ │ ▼ │ -│ ┌────────────────────────────────────────────────────────────────────────┐ │ -│ │ Storage Layer │ │ -│ ├────────┬──────┬───────┬─────┬───────────┬───────────────┬────────────┤ │ -│ │ Memory │ FS │ Redis │ LRU │ LRU+Redis │ Elasticsearch │ Memcached │ │ -│ └────────┴──────┴───────┴─────┴───────────┴───────────────┴────────────┘ │ +│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Storage Layer │ │ +│ ├────────┬──────┬───────┬─────┬───────────┬───────────────┬────────────┬──────────┤ │ +│ │ Memory │ FS │ Redis │ LRU │ LRU+Redis │ Elasticsearch │ Memcached │ Valkey │ │ +│ └────────┴──────┴───────┴─────┴───────────┴───────────────┴────────────┴──────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -103,6 +104,7 @@ For detailed documentation, see the [main package README](./ts-cache/README.md). | **LRUWithRedisStorage** | Async | High-performance distributed | Local + remote tiers | | **ElasticsearchStorage** | Async | Search-integrated caching | Full-text search, scalable | | **MemcachedStorage** | Async | High-performance distributed | Simple, fast, widely supported | +| **ValkeyStorage** | Async | Distributed systems | Redis-compatible, open source | ## Requirements diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d158abc..6e00fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,19 @@ importers: specifier: ^8.9.0 version: 8.13.1(@types/ioredis-mock@8.2.6(ioredis@5.9.2))(ioredis@5.9.2) + storages/valkey: + dependencies: + '@node-ts-cache/core': + specifier: workspace:* + version: link:../../ts-cache + iovalkey: + specifier: ^0.2.1 + version: 0.2.2 + devDependencies: + ioredis-mock: + specifier: ^8.9.0 + version: 8.13.1(@types/ioredis-mock@8.2.6(ioredis@5.9.2))(ioredis@5.9.2) + ts-cache: devDependencies: ioredis: @@ -1234,6 +1247,10 @@ packages: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} + iovalkey@0.2.2: + resolution: {integrity: sha512-7eVmLOYV2UamZ/YPXuUwTu/4zBDxXcfjj/wmOwlKBBhU2qjg60Th0Y/cqfED3OxNAhc6hUV2Ft4eQMCKi2EMpQ==} + engines: {node: '>=18.12.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2886,6 +2903,20 @@ snapshots: transitivePeerDependencies: - supports-color + iovalkey@0.2.2: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-extglob@2.1.1: {} is-glob@4.0.3: diff --git a/storages/valkey/README.md b/storages/valkey/README.md new file mode 100644 index 0000000..4e617a3 --- /dev/null +++ b/storages/valkey/README.md @@ -0,0 +1,105 @@ +# @node-ts-cache/valkey-storage + +Valkey storage adapter for [node-ts-cache](https://github.com/simllll/node-ts-cache). + +[Valkey](https://valkey.io/) is an open-source, Redis-compatible in-memory data store that emerged as a community-driven fork after Redis changed its license. + +## Installation + +```bash +npm install @node-ts-cache/core @node-ts-cache/valkey-storage +``` + +## Usage + +```typescript +import { Cache, ExpirationStrategy } from '@node-ts-cache/core'; +import { ValkeyStorage } from '@node-ts-cache/valkey-storage'; +import Valkey from 'iovalkey'; + +const valkeyClient = new Valkey({ + host: 'localhost', + port: 6379 +}); + +const valkeyCache = new ExpirationStrategy(new ValkeyStorage(() => valkeyClient, { maxAge: 3600 })); + +class MyService { + @Cache(valkeyCache, { ttl: 60 }) + async getUsers(): Promise { + // expensive operation + } +} +``` + +## Configuration Options + +```typescript +new ValkeyStorage( + valkeyFactory: () => Valkey, + options?: { + maxAge: number; // TTL in seconds (default: 86400 = 1 day) + } +) +``` + +### Constructor Parameters + +| Parameter | Type | Description | +| ---------------- | -------------- | -------------------------------------------------- | +| `valkeyFactory` | `() => Valkey` | Factory function returning a Valkey client | +| `options` | `object` | Configuration options | +| `options.maxAge` | `number` | Default TTL in seconds (default: 86400 = 24 hours) | + +### Error Handling + +You can attach an error handler for non-blocking write operations: + +```typescript +const storage = new ValkeyStorage(() => valkeyClient, { maxAge: 3600 }); + +storage.onError(error => { + console.error('Valkey error:', error); + // Log to monitoring service, etc. +}); +``` + +### Batch Operations + +ValkeyStorage supports efficient batch operations: + +```typescript +// Get multiple items +const items = await storage.getItems(['user:1', 'user:2', 'user:3']); + +// Set multiple items +await storage.setItems([ + { key: 'user:1', content: user1 }, + { key: 'user:2', content: user2 } +]); +``` + +## Running Tests Locally + +Start Valkey using Docker: + +```bash +docker run -p 6379:6379 valkey/valkey:latest +``` + +Then run the tests: + +```bash +npm test +``` + +## Why Valkey? + +- **Open Source**: Valkey is BSD-3 licensed, ensuring it remains truly open source +- **Redis Compatible**: Drop-in replacement for Redis with full protocol compatibility +- **Community Driven**: Backed by Linux Foundation with contributions from AWS, Google, Oracle, and more +- **Active Development**: Regular releases with new features and improvements + +## License + +MIT diff --git a/storages/valkey/package.json b/storages/valkey/package.json new file mode 100644 index 0000000..7e2385b --- /dev/null +++ b/storages/valkey/package.json @@ -0,0 +1,47 @@ +{ + "name": "@node-ts-cache/valkey-storage", + "version": "1.0.0", + "description": "Valkey storage adapter for node-ts-cache", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p .", + "clean": "git clean -fdx src", + "dev": "tsc -p . -w", + "prepublishOnly": "tsc -p .", + "test": "vitest run" + }, + "dependencies": { + "@node-ts-cache/core": "workspace:*", + "iovalkey": "^0.2.1" + }, + "devDependencies": { + "ioredis-mock": "^8.9.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/simllll/node-ts-cache.git", + "directory": "storages/valkey" + }, + "keywords": [ + "cache", + "typescript", + "valkey", + "redis-compatible" + ], + "license": "MIT" +} diff --git a/storages/valkey/src/index.ts b/storages/valkey/src/index.ts new file mode 100644 index 0000000..5999920 --- /dev/null +++ b/storages/valkey/src/index.ts @@ -0,0 +1 @@ +export { ValkeyStorage } from './valkey.storage.js'; diff --git a/storages/valkey/src/valkey.storage.ts b/storages/valkey/src/valkey.storage.ts new file mode 100644 index 0000000..daa92bb --- /dev/null +++ b/storages/valkey/src/valkey.storage.ts @@ -0,0 +1,123 @@ +import { IAsynchronousCacheType, IMultiIAsynchronousCacheType } from '@node-ts-cache/core'; +import * as Valkey from 'iovalkey'; + +export class ValkeyStorage implements IAsynchronousCacheType, IMultiIAsynchronousCacheType { + constructor( + private valkey: () => Valkey.default, + private options: { + maxAge: number; + } = { maxAge: 86400 } + ) {} + + private errorHandler: ((error: Error) => void) | undefined; + + onError(listener: (error: Error) => void) { + this.errorHandler = listener; + } + + async getItems(keys: string[]): Promise<{ [key: string]: T | undefined }> { + const mget: (string | null)[] = await this.valkey().mget(...keys); + const res: { [key: string]: T | undefined } = {}; + mget.forEach((entry: string | null, i: number) => { + if (entry === null) { + res[keys[i]] = undefined; // value does not exist yet + return; + } + + if (entry === '') { + res[keys[i]] = null as T; // value does exist, but is empty + return; + } + + // Try to parse as JSON + let parsedItem: T | string = entry; + try { + if (entry) { + parsedItem = JSON.parse(entry) as T; + } + } catch { + /** Not JSON, keep as string */ + } + + res[keys[i]] = parsedItem as T; + }); + return res; + } + + async setItems( + values: { key: string; content: T | undefined }[], + options?: { ttl?: number } + ): Promise { + const pipeline = this.valkey().pipeline(); + values.forEach(val => { + if (val.content === undefined) return; + + const content: string = JSON.stringify(val.content); + + const ttl = options?.ttl ?? this.options.maxAge; + if (ttl) { + pipeline.setex(val.key, ttl, content); + } else { + pipeline.set(val.key, content); + } + }); + const savePromise = pipeline.exec(); + + if (this.errorHandler) { + // if we have an error handler, we do not need to await the result + savePromise.catch((err: unknown) => this.errorHandler && this.errorHandler(err as Error)); + } else { + await savePromise; + } + } + + public async getItem(key: string): Promise { + const entry: string | null = await this.valkey().get(key); + if (entry === null) { + return undefined; + } + if (entry === '') { + return null as T; // value exists but is empty + } + + // Try to parse as JSON + let parsedItem: T | string = entry; + try { + parsedItem = JSON.parse(entry) as T; + } catch { + /** Not JSON, keep as string */ + } + return parsedItem as T; + } + + public async setItem( + key: string, + content: T | undefined, + options?: { ttl?: number } + ): Promise { + if (content === undefined) { + await this.valkey().del(key); + return; + } + + // Serialize to string + const serialized: string = + typeof content === 'object' ? JSON.stringify(content) : String(content); + + const ttl = options?.ttl ?? this.options.maxAge; + const savePromise: Promise<'OK' | null> = ttl + ? this.valkey().setex(key, ttl, serialized) + : this.valkey().set(key, serialized); + + if (this.errorHandler) { + // if we have an error handler, we do not need to await the result + savePromise.catch((err: unknown) => this.errorHandler && this.errorHandler(err as Error)); + } else { + await savePromise; + } + } + + public async clear(): Promise { + await this.valkey().flushdb(); + } +} diff --git a/storages/valkey/test/valkey.storage.test.ts b/storages/valkey/test/valkey.storage.test.ts new file mode 100644 index 0000000..d8a9bfa --- /dev/null +++ b/storages/valkey/test/valkey.storage.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ValkeyStorage } from '../src/valkey.storage.js'; +import RedisMock from 'ioredis-mock'; + +describe('ValkeyStorage', () => { + let storage: ValkeyStorage; + let mockClient: RedisMock; + + beforeAll(async () => { + // Use ioredis-mock since iovalkey is API-compatible with ioredis + mockClient = new RedisMock(); + storage = new ValkeyStorage( + () => mockClient as unknown as ReturnType, + { + maxAge: 3600 + } + ); + }, 10000); + + afterAll(async () => { + if (mockClient) { + mockClient.disconnect(); + } + }, 10000); + + it('Should return undefined if cache not hit', async () => { + const item = await storage.getItem('nonexistent-key'); + expect(item).toBe(undefined); + }); + + it('Should set and get a string value', async () => { + await storage.setItem('testKey', 'testValue'); + const result = await storage.getItem('testKey'); + expect(result).toBe('testValue'); + }); + + it('Should set and get an object value', async () => { + const testObject = { name: 'test', value: 123 }; + await storage.setItem('objectKey', testObject); + const result = await storage.getItem('objectKey'); + expect(result).toEqual(testObject); + }); + + it('Should delete cache item when set to undefined', async () => { + await storage.setItem('deleteKey', 'toDelete'); + await storage.setItem('deleteKey', undefined); + const result = await storage.getItem('deleteKey'); + expect(result).toBe(undefined); + }); + + it('Should get multiple items', async () => { + await storage.setItem('multi1', 'value1'); + await storage.setItem('multi2', 'value2'); + const results = await storage.getItems(['multi1', 'multi2', 'nonexistent']); + expect(results['multi1']).toBe('value1'); + expect(results['multi2']).toBe('value2'); + expect(results['nonexistent']).toBe(undefined); + }); + + it('Should set multiple items', async () => { + await storage.setItems([ + { key: 'batch1', content: 'batchValue1' }, + { key: 'batch2', content: 'batchValue2' } + ]); + const result1 = await storage.getItem('batch1'); + const result2 = await storage.getItem('batch2'); + expect(result1).toBe('batchValue1'); + expect(result2).toBe('batchValue2'); + }); + + it('Should clear all items', async () => { + await storage.setItem('clearKey', 'clearValue'); + await storage.clear(); + const result = await storage.getItem('clearKey'); + expect(result).toBe(undefined); + }); +}); diff --git a/storages/valkey/tsconfig.json b/storages/valkey/tsconfig.json new file mode 100644 index 0000000..ef88f77 --- /dev/null +++ b/storages/valkey/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/ts-cache/ADVANCED.md b/ts-cache/ADVANCED.md index d9d3060..4eb9ccb 100644 --- a/ts-cache/ADVANCED.md +++ b/ts-cache/ADVANCED.md @@ -379,6 +379,50 @@ const strategy = new ExpirationStrategy(storage); - Lower memory overhead than Redis - No persistence (data lost on restart) +### ValkeyStorage + +Valkey storage using [iovalkey](https://github.com/valkey-io/iovalkey), the official Valkey client (ioredis-compatible). + +```bash +npm install @node-ts-cache/valkey-storage +``` + +```typescript +import { ExpirationStrategy } from '@node-ts-cache/core'; +import { ValkeyStorage } from '@node-ts-cache/valkey-storage'; +import Valkey from 'iovalkey'; + +const valkeyClient = new Valkey({ + host: 'localhost', + port: 6379 +}); + +const storage = new ValkeyStorage(() => valkeyClient, { + maxAge: 3600 // TTL in seconds +}); + +// With error handler (non-blocking writes) +storage.onError(error => { + console.error('Valkey error:', error); +}); + +const strategy = new ExpirationStrategy(storage); +``` + +**Constructor Options:** + +| Option | Type | Default | Description | +| -------- | -------- | ------- | ------------------------------- | +| `maxAge` | `number` | `86400` | TTL in seconds (used by Valkey) | + +**Characteristics:** + +- Asynchronous operations +- Supports multi-get/set operations +- Redis-compatible (drop-in replacement) +- Open source (BSD-3 license) +- Backed by Linux Foundation + ## @MultiCache Details ### Signature diff --git a/ts-cache/README.md b/ts-cache/README.md index 177f405..d85f330 100644 --- a/ts-cache/README.md +++ b/ts-cache/README.md @@ -42,6 +42,7 @@ The core package includes `MemoryStorage` and `FsJsonStorage`. Additional storag | `@node-ts-cache/lru-redis-storage` | LRU + Redis | Async | Two-tier: fast local + shared remote | | `@node-ts-cache/elasticsearch-storage` | [elasticsearch](https://www.npmjs.com/package/@elastic/elasticsearch) | Async | Search-integrated caching | | `@node-ts-cache/memcached-storage` | [memcached](https://www.npmjs.com/package/memcached) | Async | High-performance distributed cache | +| `@node-ts-cache/valkey-storage` | [iovalkey](https://www.npmjs.com/package/iovalkey) | Async | Redis-compatible, open source | ## Decorators From b2c0ff8e21620ad5aac2a0f7492bc630044e34ce Mon Sep 17 00:00:00 2001 From: Simon Tretter Date: Thu, 22 Jan 2026 22:32:05 +0100 Subject: [PATCH 4/5] Add Elasticsearch and Memcached storage adapters This changeset introduces patch versions for the core and storage adapters, including Elasticsearch and Memcached. --- .changeset/spotty-lions-bake.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/spotty-lions-bake.md diff --git a/.changeset/spotty-lions-bake.md b/.changeset/spotty-lions-bake.md new file mode 100644 index 0000000..37f8049 --- /dev/null +++ b/.changeset/spotty-lions-bake.md @@ -0,0 +1,8 @@ +--- +"@node-ts-cache/core": patch +"@node-ts-cache/elasticsearch-storage": patch +"@node-ts-cache/memcached-storage": patch +"@node-ts-cache/valkey-storage": patch +--- + +Add Elasticsearch and Memcached storage adapters From d09648151e497add4211aa791da955f41c4dad65 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 21:39:23 +0000 Subject: [PATCH 5/5] test: use mocks for storage adapter tests Update tests for Elasticsearch, Memcached, and Redis storage adapters to use mock clients instead of requiring real service connections. This allows tests to run in CI without needing external services. Changes: - Elasticsearch: Add MockElasticsearchClient class for testing - Memcached: Add client injection support and MockMemcached class - Redis: Add client injection support and MockRedisClient class All adapters now accept a pre-configured client in their options, which enables easy mocking for tests while maintaining backward compatibility for production use. --- .../test/elasticsearch.storage.test.ts | 81 +++++++++++++------ storages/memcached/src/memcached.storage.ts | 8 +- .../memcached/test/memcached.storage.test.ts | 79 +++++++++++------- storages/redis/src/index.ts | 3 +- storages/redis/src/redis.storage.ts | 22 +++-- storages/redis/test/redis.storage.test.ts | 51 ++++++++---- 6 files changed, 170 insertions(+), 74 deletions(-) diff --git a/storages/elasticsearch/test/elasticsearch.storage.test.ts b/storages/elasticsearch/test/elasticsearch.storage.test.ts index c19fb40..bd1f8c8 100644 --- a/storages/elasticsearch/test/elasticsearch.storage.test.ts +++ b/storages/elasticsearch/test/elasticsearch.storage.test.ts @@ -1,33 +1,68 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { ElasticsearchStorage } from '../src/elasticsearch.storage.js'; -// Requires Elasticsearch - use ELASTICSEARCH_NODE env var or defaults to localhost:9200 -// In CI: provided by Elasticsearch service container -// Locally: docker run -p 9200:9200 -e "discovery.type=single-node" -e "xpack.security.enabled=false" elasticsearch:8.12.0 -const node = process.env.ELASTICSEARCH_NODE || 'http://localhost:9200'; -const indexName = 'node-ts-cache-test'; +// Mock Elasticsearch client for testing +class MockElasticsearchClient { + private store: Map = new Map(); -let storage: ElasticsearchStorage; + async get({ index, id }: { index: string; id: string }) { + const key = `${index}:${id}`; + if (!this.store.has(key)) { + const error = new Error('Not found'); + (error as Error & { meta?: { statusCode: number } }).meta = { statusCode: 404 }; + throw error; + } + return { _source: this.store.get(key) }; + } + + async index({ + index, + id, + document + }: { + index: string; + id: string; + refresh?: string; + document: unknown; + }) { + const key = `${index}:${id}`; + this.store.set(key, document); + return { result: 'created' }; + } + + async delete({ index, id }: { index: string; id: string; refresh?: string }) { + const key = `${index}:${id}`; + if (!this.store.has(key)) { + const error = new Error('Not found'); + (error as Error & { meta?: { statusCode: number } }).meta = { statusCode: 404 }; + throw error; + } + this.store.delete(key); + return { result: 'deleted' }; + } + + indices = { + delete: async () => { + this.store.clear(); + return { acknowledged: true }; + } + }; + + async close() { + // no-op + } +} describe('ElasticsearchStorage', () => { - beforeAll(async () => { + let storage: ElasticsearchStorage; + let mockClient: MockElasticsearchClient; + + beforeEach(() => { + mockClient = new MockElasticsearchClient(); storage = new ElasticsearchStorage({ - indexName, - clientOptions: { node } + indexName: 'test-index', + client: mockClient as unknown as import('@elastic/elasticsearch').Client }); - // Clear any existing test data - await storage.clear(); - }, 30000); - - afterAll(async () => { - if (storage) { - await storage.clear(); - await storage.close(); - } - }, 10000); - - it('Should clear Elasticsearch index without errors', async () => { - await storage.clear(); }); it('Should return undefined if cache not hit', async () => { diff --git a/storages/memcached/src/memcached.storage.ts b/storages/memcached/src/memcached.storage.ts index ff4015f..5b46f0c 100644 --- a/storages/memcached/src/memcached.storage.ts +++ b/storages/memcached/src/memcached.storage.ts @@ -6,13 +6,19 @@ export interface MemcachedStorageOptions { location: Memcached.Location; /** Memcached client options */ options?: Memcached.options; + /** Pre-configured Memcached client instance (takes precedence over location) */ + client?: Memcached; } export class MemcachedStorage implements IAsynchronousCacheType { private client: Memcached; constructor(options: MemcachedStorageOptions) { - this.client = new Memcached(options.location, options.options); + if (options.client) { + this.client = options.client; + } else { + this.client = new Memcached(options.location, options.options); + } } public async getItem(key: string): Promise { diff --git a/storages/memcached/test/memcached.storage.test.ts b/storages/memcached/test/memcached.storage.test.ts index 120fba6..11b95f3 100644 --- a/storages/memcached/test/memcached.storage.test.ts +++ b/storages/memcached/test/memcached.storage.test.ts @@ -1,59 +1,78 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { MemcachedStorage } from '../src/index.js'; +import type Memcached from 'memcached'; + +// Mock Memcached client for testing +class MockMemcached { + private store: Map = new Map(); + + get(key: string, callback: (err: Error | undefined, data: string | undefined) => void): void { + const data = this.store.get(key); + callback(undefined, data); + } + + set(key: string, value: string, _ttl: number, callback: (err: Error | undefined) => void): void { + this.store.set(key, value); + callback(undefined); + } + + del(key: string, callback: (err: Error | undefined) => void): void { + this.store.delete(key); + callback(undefined); + } + + flush(callback: (err: Error | undefined) => void): void { + this.store.clear(); + callback(undefined); + } + + end(): void { + // no-op + } +} describe('MemcachedStorage', () => { let storage: MemcachedStorage; + let mockClient: MockMemcached; - // These tests require a running Memcached instance - // Skip in CI unless Memcached is available - const memcachedHost = process.env.MEMCACHED_HOST || 'localhost'; - const memcachedPort = process.env.MEMCACHED_PORT || '11211'; - - beforeAll(() => { + beforeEach(() => { + mockClient = new MockMemcached(); storage = new MemcachedStorage({ - location: `${memcachedHost}:${memcachedPort}` + location: 'mock:11211', + client: mockClient as unknown as Memcached }); - }, 10000); - - afterAll(() => { - if (storage) { - storage.end(); - } - }, 10000); + }); it('Should return undefined if cache not hit', async () => { - const item = await storage.getItem('nonexistent-key-' + Date.now()); + const item = await storage.getItem('nonexistent-key'); expect(item).toBe(undefined); }); it('Should set and get a string value', async () => { - const key = 'test-string-' + Date.now(); - await storage.setItem(key, 'testValue'); - const result = await storage.getItem(key); + await storage.setItem('testKey', 'testValue'); + const result = await storage.getItem('testKey'); expect(result).toBe('testValue'); }); it('Should set and get an object value', async () => { - const key = 'test-object-' + Date.now(); const content = { data: { name: 'test', value: 123 } }; - await storage.setItem(key, content); - const result = await storage.getItem(key); + await storage.setItem('objectKey', content); + const result = await storage.getItem('objectKey'); expect(result).toEqual(content); }); it('Should delete cache item if set to undefined', async () => { - const key = 'test-delete-' + Date.now(); - await storage.setItem(key, 'value'); - await storage.setItem(key, undefined); - const result = await storage.getItem(key); + await storage.setItem('deleteKey', 'value'); + await storage.setItem('deleteKey', undefined); + const result = await storage.getItem('deleteKey'); expect(result).toBe(undefined); }); it('Should clear all items', async () => { - const key = 'test-clear-' + Date.now(); - await storage.setItem(key, 'value'); + await storage.setItem('key1', 'value1'); + await storage.setItem('key2', 'value2'); await storage.clear(); - const result = await storage.getItem(key); - expect(result).toBe(undefined); + expect(await storage.getItem('key1')).toBe(undefined); + expect(await storage.getItem('key2')).toBe(undefined); }); }); diff --git a/storages/redis/src/index.ts b/storages/redis/src/index.ts index b93081c..8d0916e 100644 --- a/storages/redis/src/index.ts +++ b/storages/redis/src/index.ts @@ -1,3 +1,4 @@ -import { RedisStorage } from './redis.storage.js'; +import { RedisStorage, RedisStorageOptions } from './redis.storage.js'; +export { RedisStorage, RedisStorageOptions }; export default RedisStorage; diff --git a/storages/redis/src/redis.storage.ts b/storages/redis/src/redis.storage.ts index de41d77..2ce16bc 100644 --- a/storages/redis/src/redis.storage.ts +++ b/storages/redis/src/redis.storage.ts @@ -3,17 +3,29 @@ import { createClient, RedisClientOptions } from 'redis'; type RedisClient = ReturnType; +export interface RedisStorageOptions extends RedisClientOptions { + /** Pre-configured Redis client instance (takes precedence over other options) */ + client?: RedisClient; +} + export class RedisStorage implements IAsynchronousCacheType { private client: RedisClient; - private connectionPromise: Promise; + private connectionPromise: Promise | null; - constructor(redisOptions?: RedisClientOptions) { - this.client = createClient(redisOptions); - this.connectionPromise = this.client.connect(); + constructor(redisOptions?: RedisStorageOptions) { + if (redisOptions?.client) { + this.client = redisOptions.client; + this.connectionPromise = null; // Already connected + } else { + this.client = createClient(redisOptions); + this.connectionPromise = this.client.connect(); + } } private async ensureConnected(): Promise { - await this.connectionPromise; + if (this.connectionPromise) { + await this.connectionPromise; + } } public async getItem(key: string): Promise { diff --git a/storages/redis/test/redis.storage.test.ts b/storages/redis/test/redis.storage.test.ts index 3e8ac46..3b5f556 100644 --- a/storages/redis/test/redis.storage.test.ts +++ b/storages/redis/test/redis.storage.test.ts @@ -1,24 +1,47 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { RedisStorage } from '../src/redis.storage.js'; +import type { createClient } from 'redis'; -// Requires Redis - use REDIS_HOST/REDIS_PORT env vars or defaults to localhost:6379 -// In CI: provided by Redis service container -// Locally: docker run -p 6379:6379 redis:7 -const host = process.env.REDIS_HOST || 'localhost'; -const port = Number(process.env.REDIS_PORT) || 6379; +type RedisClient = ReturnType; -let storage: RedisStorage; +// Mock Redis client for testing +class MockRedisClient { + private store: Map = new Map(); + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async set(key: string, value: string): Promise<'OK'> { + this.store.set(key, value); + return 'OK'; + } + + async del(key: string): Promise { + this.store.delete(key); + return 1; + } + + async flushDb(): Promise<'OK'> { + this.store.clear(); + return 'OK'; + } + + async quit(): Promise<'OK'> { + return 'OK'; + } +} describe('RedisStorage', () => { - beforeAll(async () => { + let storage: RedisStorage; + let mockClient: MockRedisClient; + + beforeEach(() => { + mockClient = new MockRedisClient(); storage = new RedisStorage({ - socket: { host, port } + client: mockClient as unknown as RedisClient }); - }, 10000); - - afterAll(async () => { - if (storage) await storage.disconnect(); - }, 10000); + }); it('Should clear Redis without errors', async () => { await storage.clear();