├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── index.d.ts ├── lib └── index.js ├── license ├── package.json ├── readme.md └── test └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: lukeed 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags-ignore: 8 | - '**' 9 | paths-ignore: 10 | - '*.md' 11 | 12 | pull_request: 13 | branches: 14 | - master 15 | paths-ignore: 16 | - '*.md' 17 | 18 | jobs: 19 | test: 20 | name: Node.js v${{ matrix.nodejs }} 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | nodejs: [8, 10, 12, 14] 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-node@v1 28 | with: 29 | node-version: ${{ matrix.nodejs }} 30 | 31 | - name: (cache) restore 32 | uses: actions/cache@master 33 | with: 34 | path: node_modules 35 | key: ${{ runner.os }}-${{ hashFiles('**/package.json') }} 36 | 37 | - name: Install 38 | run: npm install 39 | 40 | - name: (coverage) Install 41 | if: matrix.nodejs >= 14 42 | run: npm install -g c8 43 | 44 | - name: Test 45 | run: npm test 46 | if: matrix.nodejs < 14 47 | 48 | - name: (coverage) Test 49 | run: c8 --include=lib npm test 50 | if: matrix.nodejs >= 14 51 | 52 | - name: (coverage) Report 53 | if: matrix.nodejs >= 14 54 | run: | 55 | c8 report --reporter=text-lcov > coverage.lcov 56 | bash <(curl -s https://codecov.io/bash) 57 | env: 58 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *-lock.* 4 | *.lock 5 | *.log 6 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare interface Options { 2 | max?: number; 3 | maxAge?: number; 4 | stale?: boolean; 5 | } 6 | 7 | declare class Cache extends Map { 8 | constructor(options?: Options | number); 9 | get(key: K, refresh?: boolean): V | undefined; 10 | peek(key: K): V | undefined; 11 | set(key: K, value: V, maxAge?: number): this; 12 | } 13 | 14 | export = Cache; 15 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | class Cache extends Map { 2 | constructor(opts={}) { 3 | super(); 4 | 5 | if (typeof opts === 'number') { 6 | opts = { max:opts }; 7 | } 8 | 9 | let { max, maxAge } = opts; 10 | this.max = max > 0 && max || Infinity; 11 | this.maxAge = maxAge !== void 0 ? maxAge : -1; 12 | this.stale = !!opts.stale; 13 | } 14 | 15 | peek(key) { 16 | return this.get(key, false); 17 | } 18 | 19 | set(key, content, maxAge = this.maxAge) { 20 | this.has(key) && this.delete(key); 21 | (this.size + 1 > this.max) && this.delete(this.keys().next().value); 22 | let expires = maxAge > -1 && (maxAge + Date.now()); 23 | return super.set(key, { expires, content }); 24 | } 25 | 26 | get(key, mut=true) { 27 | let x = super.get(key); 28 | if (x === void 0) return x; 29 | 30 | let { expires, content } = x; 31 | if (expires !== false && Date.now() >= expires) { 32 | this.delete(key); 33 | return this.stale ? content : void 0; 34 | } 35 | 36 | if (mut) this.set(key, content); 37 | return content; 38 | } 39 | } 40 | 41 | module.exports = Cache; 42 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Luke Edwards (lukeed.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tmp-cache", 3 | "version": "1.1.0", 4 | "repository": "lukeed/tmp-cache", 5 | "description": "A least-recently-used cache in 35 lines of code", 6 | "main": "lib/index.js", 7 | "types": "index.d.ts", 8 | "license": "MIT", 9 | "author": { 10 | "name": "Luke Edwards", 11 | "email": "luke.edwards05@gmail.com", 12 | "url": "https://lukeed.com" 13 | }, 14 | "engines": { 15 | "node": ">=6" 16 | }, 17 | "scripts": { 18 | "test": "uvu test" 19 | }, 20 | "files": [ 21 | "*.d.ts", 22 | "lib" 23 | ], 24 | "keywords": [ 25 | "cache", 26 | "lru", 27 | "lru-cache", 28 | "mru" 29 | ], 30 | "devDependencies": { 31 | "uvu": "0.3.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # tmp-cache ![CI](https://github.com/lukeed/tmp-cache/workflows/CI/badge.svg) [![codecov](https://badgen.net/codecov/c/github/lukeed/tmp-cache)](https://codecov.io/gh/lukeed/tmp-cache) 2 | 3 | > A least-recently-used cache in 35 lines of code~! 4 | 5 | LRU caches operate on a first-in-first-out queue. This means that the first item is the oldest and will therefore be deleted once the `max` limit has been reached. 6 | 7 | When a `maxAge` value is set, items are given an expiration date. This allows existing items to become stale over time which, depending on your `stale` config, is equivalent to the item not existing at all! 8 | 9 | In order to counteract this idle decay, all `set()` and `get()` operations on an item "refresh" its expiration date. By doing so, a new `expires` value is issued & the item is moved to the end of the list — aka, it's the newest kid on the block! 10 | 11 | 12 | ## Install 13 | 14 | ``` 15 | $ npm install --save tmp-cache 16 | ``` 17 | 18 | 19 | ## Usage 20 | 21 | ```js 22 | const Cache = require('tmp-cache'); 23 | 24 | let cache = new Cache(3); // sets "max" size 25 | 26 | cache.set('a', 1); //~> ['a'] 27 | cache.set('b', 2); //~> ['a', 'b'] 28 | cache.set('c', 3); //~> ['a', 'b', 'c'] 29 | cache.get('a'); //~> ['b', 'c', 'a'] 30 | cache.set('d', 4); //~> ['c', 'a', 'd'] 31 | cache.peek('a'); //~> ['c', 'a', 'd'] 32 | cache.delete('d'); //~> ['c', 'a'] 33 | cache.has('d'); //=> false 34 | cache.set('e', 5); //~> ['c', 'a', 'e'] 35 | cache.size; //=> 3 36 | cache.clear(); //~> [] 37 | 38 | cache = new Cache({ maxAge:10 }); 39 | 40 | cache.set(123, 'hello'); //~> valid for 10ms 41 | cache.get(123); //=> 'hello' -- resets 10ms counter 42 | setTimeout(_ => cache.get(123), 25); //=> undefined 43 | 44 | cache = new Cache({ maxAge:0, stale:true }); 45 | 46 | cache.set('foo', [123]); //~> already stale, 0ms lifespan 47 | cache.get('foo'); //=> [123] -- because options.stale 48 | cache.get('foo'); //=> undefined -- previous op flagged removal 49 | ``` 50 | 51 | ## API 52 | 53 | Aside from the items & changes mentioned below, `tmp-cache` extends the `Map` class, so all [properties and methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#Map_instances) are inherited. 54 | 55 | ### Cache(options) 56 | 57 | Returns: `Cache extends Map` 58 | 59 | #### options.max 60 | 61 | Type: `Number`
62 | Default: `Infinity` 63 | 64 | The maximum number of items the cache will hold. Adding more entries will force the oldest, least-recently-used item to be purged. 65 | 66 | Failure to include any `max` restriction could potentially allow infinite unique entries! They will only be purged based on their `expires` value (if set). 67 | 68 | > **Note:** If `options` is an integer, then it is used as the `options.max` value. 69 | 70 | #### options.maxAge 71 | 72 | Type: `Number`
73 | Default: `-1` 74 | 75 | The maximum age (in ms) an item is considered valid; aka, its lifespan. 76 | 77 | Items are not pro-actively pruned out as they age, but if you try to access an item that has expired, it will be purged and, by default, result in an `undefined` response. 78 | 79 | #### options.stale 80 | 81 | Type: `Boolean`
82 | Default: `false` 83 | 84 | Allow an expired/stale item's value to be returned before deleting it. 85 | 86 | 87 | ### Cache.set(key, value, maxAge?) 88 | 89 | Persists the item and its value into the Cache. If a `maxAge` value exists (via custom or cache-level options), an expiration date will also be stored. 90 | 91 | When setting or updating an item that already exists, the original is removed. This allows the new item to be unique & the most recently used! 92 | 93 | #### key 94 | Type: `String` 95 | 96 | The item's unique identifier. 97 | 98 | #### value 99 | Type: `Mixed` 100 | 101 | The item's value to cache. 102 | 103 | #### maxAge 104 | Type: `Number`
105 | Default: `options.maxAge` 106 | 107 | Optionally override the [`options.maxAge`](#optionsmaxage) for this (single) operation. 108 | 109 | 110 | ### Cache.get(key, mutate?) 111 | 112 | Retrieve an item's value by its key name. By default, this operation will refresh/update the item's expiration date. 113 | 114 | May also return `undefined` if the item does not exist, or if it has expired & [`stale`](#optionsstale) is not set. 115 | 116 | #### key 117 | Type: `String` 118 | 119 | The item's unique identifier. 120 | 121 | #### mutate 122 | Type: `Boolean`
123 | Default: `true` 124 | 125 | Refresh the item's expiration date, marking it as _more_ recently used. 126 | 127 | 128 | ### Cache.peek(key) 129 | 130 | Return an item's value without updating its position or refreshing its expiration date. 131 | 132 | May also return `undefined` if the item does not exist, or if it has expired & [`stale`](#optionsstale) is not set. 133 | 134 | #### key 135 | Type: `String` 136 | 137 | The item's unique identifier. 138 | 139 | 140 | 141 | ## License 142 | 143 | MIT © [Luke Edwards](https://lukeed.com) 144 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const { test } = require('uvu'); 2 | const assert = require('uvu/assert'); 3 | const Cache = require('../lib'); 4 | 5 | const sleep = ms => new Promise(r => setTimeout(r, ms)); 6 | 7 | test('tmp-cache', () => { 8 | assert.type(Cache, 'function', 'exports a function'); 9 | assert.throws(Cache, `Cache cannot be invoked without 'new'`, '~> requires "new" invoke'); 10 | 11 | let foo = new Cache(); 12 | assert.ok(foo instanceof Cache, '~> instance of "Cache" class'); 13 | assert.ok(foo instanceof Map, '~> instance of "Map" class'); 14 | }); 15 | 16 | test('new Cache()', () => { 17 | let foo = new Cache(); 18 | assert.is(foo.max, Infinity, '~> "max" option is `Infinity` (default)'); 19 | assert.is(foo.stale, false, '~> "stale" option is `false` (default)'); 20 | assert.is(foo.maxAge, -1, '~> "maxAge" option is `-1` (default)'); 21 | }); 22 | 23 | test('new Cache(max)', () => { 24 | let foo = new Cache(5); 25 | assert.is(foo.stale, false, '~> "stale" option is `false` (default)'); 26 | assert.is(foo.maxAge, -1, '~> "maxAge" option is `-1` (default)'); 27 | assert.is(foo.max, 5, '~> "max" option is `5`'); 28 | }); 29 | 30 | test('new Cache({ max, maxAge, stale })', () => { 31 | let foo = new Cache({ max:100, stale:true, maxAge:1e3 }); 32 | assert.is(foo.maxAge, 1000, '~> "maxAge" option is set to `1000`'); 33 | assert.is(foo.stale, true, '~> "stale" option is set to `true`'); 34 | assert.is(foo.max, 100, '~> "max" option is set to `100`'); 35 | }); 36 | 37 | test('Cache.set', () => { 38 | let key=123, val=456; 39 | let foo = new Cache(); 40 | 41 | foo.set(key, val); 42 | assert.ok(foo.has(key), '~> persists key'); 43 | assert.is(foo.get(key), val, '~> key value is returned'); 44 | assert.ok(foo.has(key), '~~> key is not purged'); 45 | 46 | foo.set(key, val, 1e3); 47 | assert.ok(foo.has(key), '~> persists key'); 48 | 49 | assert.is(foo.get(key), val, '~> key is valid w/ content (maxAge)'); 50 | 51 | let obj = foo.values().next().value; 52 | assert.type(obj, 'object', 'entry always written as object'); 53 | assert.ok(obj.expires !== void 0, '~> entry has "expires" key'); 54 | assert.is(obj.expires, false, '~~> is `false` when not configured'); 55 | assert.ok(obj.content, '~> entry has "content" key'); 56 | assert.is(obj.content, val, '~~> is the `value` provided'); 57 | 58 | let bar = new Cache({ maxAge:1 }); 59 | bar.set(key, val); 60 | let { expires } = bar.values().next().value; 61 | assert.ok(expires !== void 0, '~> entry has "expires" key'); 62 | assert.type(expires, 'number', '~~> is a number when set'); 63 | }); 64 | 65 | test('Cache.set (max)', () => { 66 | let arr, foo=new Cache(5); 67 | 68 | Array.from({ length:4 }, (_, x) => foo.set(x)); 69 | assert.is(foo.size, 4, '~> initially 4 items'); 70 | 71 | foo.set(10); 72 | assert.is(foo.size, 5, '~> 5 items'); 73 | 74 | arr = Array.from(foo.keys()); 75 | assert.equal(arr, [0,1,2,3,10], '~> initial key list (ordered)'); 76 | 77 | foo.set('cow'); 78 | assert.is(foo.size, 5, '~> still 5 items'); 79 | 80 | arr = Array.from(foo.keys()); 81 | assert.equal(arr, [1,2,3,10,'cow'], '~> purged oldest key to set newest key'); 82 | }); 83 | 84 | test('Cache.get', async () => { 85 | let key = 'hi'; 86 | let foo = new Cache({ maxAge:10 }); 87 | let bar = new Cache({ stale:true, maxAge:10 }); 88 | 89 | foo.set(key, 1); 90 | assert.is(foo.get(key), 1, '~> matches value'); 91 | assert.is(foo.get(key), 1, '~> matches value (repeat)'); 92 | await sleep(25); 93 | assert.is(foo.get(key), undefined, '~> item expired'); 94 | assert.not.ok(foo.has(key), '~> item removed'); 95 | 96 | bar.set(key, 1); 97 | assert.is(bar.get(key), 1, '~> matches value'); 98 | assert.is(bar.get(key), 1, '~> matches value (repeat)'); 99 | await sleep(25); 100 | assert.is(bar.get(key), 1, '~> matches value (stale)'); 101 | assert.not.ok(bar.has(key), '~> item removed'); 102 | }); 103 | 104 | test('Cache.get :: expires', async () => { 105 | let key=123, val=456; 106 | let foo = new Cache({ maxAge:25 }); 107 | let toObj = () => sleep(3).then(() => foo.values().next().value); 108 | 109 | foo.set(key, val); 110 | let old = await toObj(); 111 | 112 | await sleep(15); 113 | assert.is(foo.get(key), val, '~> matches value'); 114 | 115 | let x = await toObj(); 116 | assert.is.not(x.expires, old.expires, '~> updates the "expires" value'); 117 | 118 | await sleep(15); 119 | assert.is(foo.get(key), val, '~~> matches value'); 120 | 121 | let y = await toObj(); 122 | assert.is.not(y.expires, x.expires, '~~> updates the "expires" value'); 123 | }); 124 | 125 | test('Cache.peek', () => { 126 | let key=123, val=456; 127 | let foo = new Cache(); 128 | let bar = new Cache({ maxAge:0 }); 129 | 130 | foo.set(key, val); 131 | assert.is(foo.peek(key), val, '~> receives value'); 132 | assert.ok(foo.has(key), '~> retains key'); 133 | 134 | bar.set(key, val); 135 | assert.is(bar.peek(key), undefined, '~> receives undefined (stale:false)'); 136 | assert.not.ok(bar.has(key), '~> triggers key deletion'); 137 | 138 | }); 139 | 140 | test('Cache.peek :: stale', () => { 141 | let key=123, val=456; 142 | let foo = new Cache({ maxAge:0, stale:true }); 143 | 144 | foo.set(key, val); 145 | let abc = foo.peek(key); 146 | assert.is(abc, val, '~> receives value (stale)'); 147 | assert.not.ok(foo.has(key), '~> triggers purge'); 148 | 149 | }); 150 | 151 | test('Cache.peek :: maxAge', () => { 152 | let key=123, val=456; 153 | let foo = new Cache({ maxAge:1e3 }); 154 | let toObj = () => foo.values().next().value; 155 | 156 | foo.set(key, val); 157 | let old = toObj(); 158 | 159 | let abc = foo.peek(key); 160 | assert.is(abc, val, '~> receives the value'); 161 | assert.ok(foo.has(key), '~> key remains if not stale'); 162 | 163 | let x = toObj(); 164 | assert.is(x.expires, old.expires, '~> expiration is unchanged'); 165 | 166 | foo.peek(key); 167 | let y = toObj(); 168 | assert.is(y.expires, old.expires, '~> expiration is unchanged'); 169 | 170 | }); 171 | 172 | test('Cache.size', () => { 173 | let foo = new Cache(); 174 | 175 | foo.set(1, 1, 0); // expire instantly 176 | assert.is(foo.size, 1, '~> 1'); 177 | 178 | foo.set(2, 2); 179 | assert.is(foo.size, 2, '~> 2'); 180 | 181 | foo.get(1); // expired & deleted 182 | assert.is(foo.size, 1, '~> 1'); 183 | 184 | }); 185 | 186 | test('least recently set', () => { 187 | let foo = new Cache(2); 188 | foo.set('a', 'A'); 189 | foo.set('b', 'B'); 190 | foo.set('c', 'C'); 191 | assert.is(foo.get('c'), 'C'); 192 | assert.is(foo.get('b'), 'B'); 193 | assert.is(foo.get('a'), undefined); 194 | }); 195 | 196 | test('lru recently gotten', () => { 197 | let foo = new Cache(2); 198 | foo.set('a', 'A'); 199 | foo.set('b', 'B'); 200 | foo.get('a'); 201 | foo.set('c', 'C'); 202 | assert.is(foo.get('c'), 'C'); 203 | assert.is(foo.get('b'), undefined); 204 | assert.is(foo.get('a'), 'A'); 205 | }); 206 | 207 | test('Cache.delete', () => { 208 | let foo = new Cache(2) 209 | foo.set('a', 'A'); 210 | foo.delete('a'); 211 | assert.is(foo.get('a'), undefined); 212 | }); 213 | 214 | test.run(); 215 | --------------------------------------------------------------------------------