1.0.0-0 • Published 9 months ago

@frontacles/cachemap v1.0.0-0

Weekly downloads
-
License
WTFPL
Repository
github
Last release
9 months ago

CacheMap

The CacheMap class extends the Map object to use it as a key-value cache.

It shines in situations when you want to cache values that are derived state or that are the result of an async operation (e.g. fetch).

Node.js CI

The package is lightweight (~ 0.5 KB compressed, not tree-shakeable (it’s a class!), typed and tested.

It’s mostly inspired by how Laravel Cache::remember works.

Installation

Install the package:

npm install @frontacles/cachemap

Import CacheMap in your script:

import CacheMap from '@frontacles/cachemap'

Not using a package manager? Download the package files in your project and take the files in /src.

The CacheMap class

CacheMap brings some methods that can all cache values.

The methods specific to CacheMap are all designed to create a new item in the cache: if the key already exists, the cache item won’t be touched.

If you want to touch a cached item, you can use the regular Map methods, all available in CacheMap, all inherited from Map:

Overview

Create a cache (or many caches):

import CacheMap from '@frontacles/cachemap'

const cache = new CacheMap() // no parameter creates an empty cache

const SuperMarioBros3Cache = new CacheMap([ // init with array of ['key', value]
  ['key', 'value'],
  ['country', 'Mushroom Kingdom'],
  ['hierarchy', {
    boss: 'Bowser',
    chiefs: ['Lemmy', 'Iggy', 'Morton', 'Larry', 'Ludwig', 'Wendy', 'Roy'],
    randos: ['Goomba', 'Koopa Troopa', 'Cheep cheep', 'Pirhana Plant']
  }],
])

Add new items in the cache using CacheMap.add:

const cache = SuperMarioBros3Cache // rename our SMB3 cache, for convenience

cache
  .add('plumbers', ['Mario', 'Luigi']) // returns `cache`, allowing chaining
  .add('tiny assistant', 'Toad')
  // .clear() // uncomment this line to kill everyone

Cache and return using CacheMap.remember:

cache.remember('last visited level', '1-3') // 1-3
cache.remember('last visited level', '8-2') // still returns '1-3', it was cached!

Cache and return the computed value of a function using CacheMap.remember:

cache.remember('bonus', () => randomFrom(['Mushroom', 'Fire flower', 'Star']))

Asynchronously cache and return (as a Promise) the result of an async function using CacheMap.rememberAsync:

const tinyHouse = await cache.rememberAsync('tiny house', prepareTinyHouse)

async function prepareTinyHouse() {
  return computeChestContent().then(chest => chest.toByteBuffer())
}

CacheMap.add

CacheMap.add updates the cache if the key is new, and returns its CacheMap instance, allowing fluent usage (methods chaining).

import CacheMap from '@frontacles/cachemap'
const cache = new CacheMap()

const nextFullMoonInBrussels = new Date(Date.parse('2023-08-31T03:35:00+02:00'))

cache
  .add('next full moon', nextFullMoonInBrussels)
  .add('cloud conditions', 'hopefully decent')
  .add('next full moon', 'yesterday') // won’t be changed, key already exists!

// CacheMap(2) [Map] {
//   'next full moon' => 2023-08-13T14:04:51.876Z,
//   'cloud conditions' => 'hopefully decent'
// }

CacheMap.remember

CacheMap.remember adds caches a value to the cache, and returns it. It takes a primitive value or a callback function returning the value that is then stored in the cache.

Like CacheMap.add, it only updates the cache if the key is new. The returned value is always the cached one.

const bills = [13.52, 17, 4.20, 21.6]

cache.remember('money you owe me', () => sum(bills))

// CacheMap(1) [Map] { 'money you owe me' => 56.32 }

bills.push(25.63)

cache.remember('money you owe me', () => sum(bills))

// CacheMap(1) [Map] { 'money you owe me' => 56.32 }

On the second usage of cache.remember in the previous example, the function doesn’t run at all: as the key already exists in the cache, its value is immediatly returned.

CacheMap.rememberAsync

CacheMap.rememberAsync is excatly the same as CacheMap.remember, except that:

  • it also accepts an async function (on top of a sync one or a primitive value);
  • it returns a Promise resolving into the cached value.

This makes it handy for network operations like fetch.

const todayCelsiusInParis = () => fetch('https://wttr.in/Paris?format=j1')
  .then(response => response.json())
  .then(({ weather }) => `${weather[0].mintempC}-${[0].maxtempC}`)

const parisCelsius = await cache.rememberAsync('temperature', todayCelsiusInParis) // 17-26

// CacheMap(1) [Map] { 'temperature' => '17-26' }

cache.rememberAsync('rainy or not', 'you can hide').then(console.log) // 'you can hide'

// CacheMap(2) [Map] {
//   'temperature' => '17-26'
//   'rain' => 'you can hide'
// }

Better derived states with remember

Getters are very convenient features available in objects and classes, allowing to compute a derived value from another simply by calling a property, instead of having to manually update it with a function:

const obj = {
  log: ['a', 'b', 'c'],

  get latest() {
    return this.log[this.log.length - 1]
  },
}

console.log(obj.latest) // 'c'

Without getters, we would have need a manual operation:

const obj = {
  log: ['a', 'b', 'c'],

  latest: null,

  updateLatest: () => {
    this.latest = this.log.length - 1
  }
}

console.log(obj.latest) // null

obj.updateLatest()
console.log(obj.latest) // 'c'

obj.log.push('d') // `obj.latest` is still 'c'

obj.updateLatest()
console.log(obj.latest) // 'd'

(Or, alternatively, work around this by having a obj.latest() function doing the computation on the fly, exactly like get latest(), but it means you then have to write obj.latest() instead of obj.latest.)

Enters CacheMap.remember (and CacheMap.rememberAsync) to avoid running the get/latest() computation each time we need this data.

In the following example, the Ranking class constructor receives an array of scores, and, from there, getters are used to compute once the podium 🏆 and the average. New computations of the derived values are only needed after a new entry is pushed into the list of scores in Ranking.add.

class Ranking {

  #scores
  #cache = new CacheMap()

  constructor(scores) {
    this.#scores = scores
  }

  // Extract the podium.

  get podium() {
    return this.#cache.remember('podium', () => {
      console.log('Extracting the podium…')
      const scores = structuredClone(this.#scores)
      scores.sort((a, b) => b - a)
      return scores.slice(0, 3)
    })
  }

  // Compute the average.

  get average() {
    return this.#cache.remember('average', () => {
      console.log('Computing the average score…')

      const sum = numbers => numbers.reduce((acc, val) => acc + val, 0)
      return sum(this.#scores) / this.#scores.length
    })
  }

  // Push a new score.

  add(score) {
    this.#scores.push(score)
    this.#cache.clear() // invalidate the cache, so it gets recomputed next time we access podium or average
  }
}

const ranking = new Ranking([17, 9, 651, 4, 19.8, 231])

console.log(ranking.podium)
// Extracting the podium…
// [ 651, 231, 19.8 ]

console.log(ranking.podium) // does not print “Extracting the podium” a second time, because the cached value is returned!
// [ 651, 231, 19.8 ]

ranking.add(91)
console.log(ranking.podium) // the cache has been invalidated, so the function runs again
// Extracting the podium…
// [ 651, 231, 91 ]

As you can see, computation is only done when needed. Other example of this behaviour.

Clear the cache

You can clear the whole cache with CacheMap.clear, or only forget 1 key with CacheMap.delete.

import CacheMap from '@frontacles/cachemap'

const scores = new CacheMap()
scores.add('Elvira', '68')
scores.add('Loulou', '54')
scores.add('Mehdi', '74')

// forget 1 cache key
scores.delete('Mehdi') // [Map Iterator] { 'Elvira', 'Loulou' }

// forget all keys
scores.clear() // [Map Iterator] {  }

Ideas

(@todo: move this to issues)

  • Cache with expiration.
  • Cache until a condition is met (could be merged with previous: expiration).
  • IndexedDB save/load (IndexedDB is the only reliable browser storage that can store Map objects(because it’s compatible with Map objects: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#javascript_types)).
  • LRU (last recently used) to delete oldest created or oldest accessed items when the cache size reaches a given limit.
  • Evaluate the need/benefits to use WeakMap.
  • Enrich map with convenient functions like deleteMany. It could be part of another class extending the base CacheMap. We could name it SuperCacheMap or RichCacheMap or something like this.

Changelog

See CHANGELOG.md or the releases.

Browser and tooling support

@frontacles/cachemap is provided as module for modern browsers usage with standard JavaScript syntax:

  • it is up to you to transpile it for legacy browsers;
  • you can’t import it using require('@frontacles/cachemap'); @todo: CHECK FOR cachemap - if you don’t transpile it, DateTime requires support for class fields (Safari 14.0) starting v1.32.0.

Read more about ESModules.

Security

See the security policy.

Contributing

See the contributing guidelines.

License

The @frontacles/cachemap package is open-sourced software licensed under the DWTFYWTPL.

1.0.0-0

9 months ago