A lightweight JavaScript client side cache. Data that you fetch from your backend is cached locally, for example in an app running on a mobile device, and only fetched again when it has expired.
- Cached data is stored in memory, as a plain JavaScript object.
- Values can be anything you can store in a JavaScript variable: strings, numbers, arrays or JSON objects.
- Every value has a time to live (TTL). When it has expired, then the value is fetched again from your backend.
- Lists and their single elements have TTLs of their own. Cache a whole list and refresh single elements of it.
- Populating-Cache is not just a key=value store. Values are stored in a tree under a path, e.g.
"posts/4711.comments". - An object in the cache may reference another object in the cache via its path, e.g. a post's
createdBymay reference the user atusers/42. These references (DBrefs) are populated automatically when yougetvalues from the cache. - Subscribe to changes in the cache.
- Thoroughly tested and configurable.
- No dependencies!
- Simple usage
- Fetch a value from the backend
- Configuration
- Populating cache is a tree structure
- Path into the cache
- Time to live (TTL)
- Populate DB references (DBref)
- Subscribe to changes
- Advanced usage
- Upgrading to 6.0
- API
Install the npm dependency in your project: npm install populating-cache
import PopulatingCache from 'populating-cache' // the module exports a class
// Create a new cache instance (More details on "fetchFunc" below.)
const cache = new PopulatingCache({ fetchFunc: (path) => Promise.resolve("valueFetchedFromBackend") })
// PUT values into the cache and store them for later
cache.put("key1", "Just any value")
cache.put("key2", { foo: "bar" })
// GET a value back from the cache:
const value = await cache.get("key1") // "Just any value"
// PUT an array into the cache
cache.put("myArray", [0, 1, 2, 3])
cache.put("myArray[1]", 111) // replace one array element
cache.put("myArray[]", 4) // append to array
const arrayItem = await cache.get("myArray[2]") // 2
const fullArray = await cache.get("myArray") // [0, 111, 2, 3, 4]
// PUT an object with an _id into the cache
const aPost = { _id: 4711, title: "Blog post title" }
cache.put("posts/4711", aPost)
const cachedPost = await cache.get("posts/4711") // cachedPost === aPost
// Values that are not in the cache are fetched from the backend
const fetched = await cache.get("somethingElse") // "valueFetchedFromBackend"When you try to get a value that is either not yet in the cache or already expired, then it is fetched from the backend. Populating-cache will
- call your
fetchFunc(path)to receive the current value from the backend, putthe returned value into the cache with a fresh TTL,- and then
get(path)resolves to the value as returned by yourfetchFunc().
/**
* When a value needs to be fetched from the backend,
* then populating-cache will call this function that you must provide.
* @param {Array} path normalized path to the value that needs to be fetched,
* e.g. [{posts: 4711}, "comments"]
* @return {Promise} value that you fetched from your backend
*/
const fetchFunc = function(path) {
// [...] call backend, make REST request, etc.
return Promise.resolve(valueFetchedFromBackend)
}
const cache = new PopulatingCache({
fetchFunc: fetchFunc,
ttl: 60 * 1000 // time to live for elements in the cache
})fetchFunc is always called with the normalized form of the path. For a REST backend, cache.path2rest(path) converts it to a URL path like /posts/4711/comments.
You can also pass a fetchFunc for one call only: cache.get(path, { fetchFunc: myOtherFetchFunc }).
The option callBackend controls this. Set it in the config or for one call to get:
| Value | Behaviour of get(path) |
|---|---|
cache.CALL_BACKEND_WHEN_EXPIRED (default) |
Returns the cached value. Calls the backend only when the value is not in the cache or expired. |
cache.FORCE_BACKEND_CALL |
Always calls the backend and updates the cache. |
cache.DO_NOT_CALL_BACKEND |
Never calls the backend. Resolves to the cached value or undefined, and rejects when the value is expired. |
const fresh = await cache.get("posts/4711", { callBackend: cache.FORCE_BACKEND_CALL })If you only want to look into the cache without a Promise, then use getSync(path) or isInCache(path). They never call the backend.
Each cache instance has its own configuration. Pass the options you want to change to the constructor: new PopulatingCache({ fetchFunc, ttl: 30 * 1000 }).
Most options can also be overridden for a single call to put or get.
| Option | Default | Description |
|---|---|---|
fetchFunc |
undefined |
async (path) => value that fetches a value from your backend. Required for get. |
callBackend |
CALL_BACKEND_WHEN_EXPIRED |
When to call the backend, see above. |
fetchCollection |
false |
Fetch a missing or expired element "key/id" with its whole collection "key", see fetchCollection. |
ttl |
60000 |
Time to live of values that are put into the cache, in milliseconds. |
populate |
true |
Automatically resolve DBrefs when getting values. |
idAttr |
"_id" |
Name of the id attribute used to find elements like "posts/4711". |
referencedPathAttr |
"$refPath" |
Name of the attribute that marks a DBref, e.g. createdBy: { $refPath: "users/42" }. |
merge |
false |
When you put an object, merge its properties into the cached object instead of replacing it. |
You may simply store values under string keys. But Populating-Cache is more powerful. It stores values in a tree structure. The cached elements in this tree are identified by the path from the root of the tree to the element.
Values in the cache (normally) sit at the leaves of this tree.
Example: Cache the tags of a post with _id="af3d-e3ff". If a post with that _id does not yet exist, then it is automatically created in the cache.
cache.put(["posts/af3d-e3ff", "tags"], ["tag1", "tag2"])When you get something from the cache, you receive that cache element and everything under it.
A path defines where a value is stored in the cache. It is an array of path elements from the root of the cache to the position of the value. When you call put(path, value) then the cache walks along path and stores value at the end of it. All intermediate elements along the path (objects and arrays) are created if necessary.
| Normalized form | Shortcut | Description |
|---|---|---|
["myKey"] |
"myKey" |
Key at root level in cache |
["abc", "def", "ghi"] |
"abc.def.ghi" |
deep path into cache |
["myArray[3]"] |
"myArray[3]" |
the n-th element of an array |
["myArray[]"] |
"myArray[]" |
append to an array (only for put, only as the last path element) |
[{posts: 4711}] |
"posts/4711" |
the element of the array posts that has that _id |
["abc", {foo: "ID-4711"}, "bar"] |
"abc.foo/ID-4711.bar" |
all types of elements can appear in one path |
You may pass the shortcut form to
putandget. Populating-cache always calls yourfetchFuncwith the normalized form. Numeric ids in the shortcut form become numbers:"posts/4711"is normalized to[{posts: 4711}].
// Store value under a path
cache.put(["parentKey", "childArray[3]"], value) // cache.parentKey.childArray[3] = value
// Populating-Cache can find array items by their _id and store values under this item.
// Creator of the comment with id "4ccf-ff33" in the post with id "af3d-e3ff"
// (Here we store a user object in the cache.)
cache.put(["posts/af3d-e3ff", "comments/4ccf-ff33", "createdBy"], { _id: "14a3-2e2f", name: "John Doe", email: "john@doe.com" })
// Path elements can also be objects. This results in the same path as the example above.
cache.put([{ posts: "af3d-e3ff" }, { comments: "4ccf-ff33" }, "createdBy"], user)Each value in the cache has a time to live after which it expires. When you try to get an expired value, then it is fetched again by calling your fetchFunc().
For simple keys this is simple. But since populating-cache is a tree structure, this can become more complex.
When you fetch a deep value at the end of a long path, then all intermediate elements in the cache are also checked.
If an intermediate element is expired, then the element at this sub path is fetched.
Example:
// put a user object into the cache
cache.put("data.user", { name: "username", email: "john.doe@domain.com" })
// ... some time passes, until the user entity expires in the cache ...
const email = await cache.get("data.user.email")Since the user element is already expired, this calls fetchFunc(["data", "user"]) with the normalized sub path to the expired user element.
The returned user is put into the cache with a fresh TTL, and then its email is returned.
An array in the cache (a collection) has a TTL of its own, and each of its elements has one, too. So you can cache a whole list and refresh single elements of it:
cache.put("polls", listOfPolls) // TTL of the list and of every poll in it
cache.put("polls/5", poll5) // only refreshes the TTL of poll 5. The list's TTL is untouched.
cache.put("polls[]", newPoll) // appends a poll with its own TTLWhen checking if something is expired, the most specific TTL wins:
| Path | Expires with |
|---|---|
an element, e.g. "polls/5" or "polls[0]" |
its own TTL. If it has none, then the TTL of its collection. If that has none either, it never expires. |
the collection, e.g. "polls" |
only its own TTL |
So poll 5 that was refreshed on its own outlives the list: get("polls/5") returns it from the cache, while get("polls") already calls the backend.
A collection that was only built up from single elements (put("polls/5", …), put("polls[]", …)) has no TTL of its own and therefore
get("polls") will not call the backend for it. Put the whole list once, if you want the list to expire.
Often your backend can deliver the whole list in one call. Then set the option fetchCollection: true, in the config or for one call to get:
const poll = await cache.get("polls/5", { fetchCollection: true })When poll 5 is not in the cache or expired, then fetchFunc(["polls"]) is called instead of fetchFunc([{polls: 5}]).
The returned array is put into the cache under "polls" (with a fresh TTL for the list and every poll in it) and the poll with _id 5 is returned.
If the new list does not contain that id, then get resolves to undefined. One backend call refreshes all polls.
fetchFunc must resolve to an array in this case, otherwise get rejects.
This applies whenever the cache needs to fetch an element by id, also in the middle of a longer path like "polls/5.title".
Next to the cached values, populating-cache keeps a parallel tree of metadata. You can inspect it with getMetadata(path).
A value has { ttl, type }, where ttl is the time in milliseconds since the epoch when the value expires.
A collection additionally has $elems, which runs in parallel to the cached array, index by index:
cache.getMetadata("polls")
// {
// ttl: 1700000060000, type: "object",
// $elems: [
// { _id: 5, ttl: 1700000090000, type: "object" }, // refreshed on its own
// { _id: 7, ttl: 1700000060000, type: "object" },
// ]
// }
cache.getMetadata("polls/5") // { _id: 5, ttl: 1700000090000, type: "object" }
cache.getMetadata("polls/5.proposals") // metadata of values below an element are stored inside its entryThe names ttl, type and $elems are reserved in the metadata. getMetadata returns the nodes as they are stored, without the fallback to the collection's TTL.
get never removes anything from the cache. Call deleteExpiredElems() from time to time to free memory.
It sets expired array elements (and their entry in $elems) to undefined, so arrays keep their length.
When a collection is expired, then only its elements without a fresh TTL of their own are removed. If none are left, the collection is deleted completely.
delete(path) removes one value, emptyCache() removes everything.
The data returned by the backend may contain references to other entities. These references are inspired by MongoDB DBrefs and the populate() function of the awesome Mongoose lib.
When you get a value from the cache, then DBrefs can automatically be resolved from within the cache content. Let's use the following content of a populating-cache as an example:
cacheData = {
posts: [
{
_id: 4711,
text: "This is an example post",
comments: [
{
_id: 101,
commentText: "This is a comment by user1",
createdBy: {
$refPath: "users/901" // path to another entity in the cache
}
}
]
}
],
users: [
{ // This is the referenced user
_id: 901,
email: "user1@domain.com"
},
]
}A simple list of posts. Each post can have comments. And each comment is createdBy a user.
But the user is not stored again and again for every comment. Instead, users are cached separately under the top-level key users. Each comment then references one user: $refPath is the path to that user in the cache. (The name of this attribute can be configured with referencedPathAttr.)
When a path in a call to get(path) spans a DBref, then this ref is automatically resolved:
const path = [{ posts: 4711 }, { comments: 101 }, "createdBy", "email"]
const result = await cache.get(path)
// result is now "user1@domain.com"When a DBref is resolved, then the TTL of the referenced entity (in our example users/901) is taken into account. If that user is expired in the cache, then it is fetched from the backend, put into the cache with a fresh TTL and used to populate the DBref.
You can also populate all DBrefs with a given name in a value you already have:
const posts = await cache.get("posts")
await cache.populate(posts, "createdBy") // replaces every createdBy DBref with the referenced userWhen resolving a path, population does not change the
$refPathproperty nor the referenced element in the cache. Only the value returned byget(path)contains the resolved element. (populate(elem, refProp)however replaces the DBrefs in theelemyou pass to it.)
Set populate: false in the config or for one call to get to receive the raw DBrefs.
You can subscribe to changes in the cache. Your listener is notified when a value is put into the cache.
const cache = new PopulatingCache({ fetchFunc: fetchFunc })
const onPutListener = (path, value) => console.log(value + " was PUT into cache at path " + path)
const listener = cache.subscribe("foo", onPutListener)
cache.put("foo.some.path", "dummyValue")
// onPutListener("foo.some.path", "dummyValue") has now been called.
cache.unsubscribe(listener)By default listeners are called when a value is put at or below their path. So by default the path is a prefix.
You can listen exactly to the changes of one element by passing true as the third argument: cache.subscribe("some.path", exactListener, true). Then exactListener is only called when exactly the element at this path changes.
When you want to be notified about all changes in the cache, then register a listener at root level: cache.subscribe("", rootListener)
When a value is deleted with delete(path), then listeners are called with onPut(path, undefined).
cache.put("key", { foo: "bar" })
cache.put("key", { baz: "boo" }, { merge: true })
// cache.get("key") now resolves to { foo: "bar", baz: "boo" }merge only applies to objects. Arrays and scalar values always replace the cached value.
// Cache an array
cache.put("array", ["one", "two", "three"])
// Replace the third element of array with "item2".
// This creates the array if necessary
// and throws an error if "array" is not an array.
cache.put("array[2]", "item2")
// replace the third element of array with an object
cache.put("array[2]", { foo: "bar" })
// merge property "baz" into the third element of array.
cache.put("array[2]", { baz: "boo" }, { merge: true })
// Array now looks like this:
assert.deepEqual(await cache.get("array"), ["one", "two", {
foo: "bar",
baz: "boo"
}])
// Append "item" to array.
// Creates a new array if necessary.
// Throws an error if "array" is not an array.
cache.put("array[]", "item")
// Be careful, don't forget the brackets! This would store the String value "item"
// under the key "array" and replace your array. This is most probably not what you want.
cache.put("array", "item")// Example: User created a new comment under a blog post
const newComment = { _id: 42, text: "This is a comment" }
// Append newComment to the array of comments of the post with _id = 4711
cache.put(["posts/4711", "comments[]"], newComment)
// This throws an id-mismatch error, because newComment has _id=42 and not 9999
cache.put(["posts/4711", "comments/9999"], newComment)
// Adds _id:42 to the passed value (and warns on the console).
cache.put(["posts/4711", "comments/42"], { text: "just something" })
// Merge property "baz" into existing comment with _id = 42
cache.put(["posts/4711", "comments/42"], { baz: "boo" }, { merge: true })Object IDs can be numeric or alphanumeric UUIDs. The name of the _id property can be configured with idAttr.
IDs are compared by their string value, so "polls/5", {polls: 5} and {polls: "5"} all address the element with _id: 5 or _id: "5".
Only objects can be stored under an id. put("posts/4711", "a string") throws an error.
cache.remember(path, fetchFunc) always calls the given fetchFunc(path), puts the result into the cache and resolves to it. Use it when you load data outside of get, but want it cached.
Version 6.0 gives collections a TTL of their own and one TTL per element. What changed for you:
getMetadata()returns a new shape for collections:{ ttl, type, $elems: [ … ] }instead of either{ ttl, type }or an array of element metadata.- Values that never expired before now do expire. In 5.x, elements of a list put with
put("polls", list)had no TTL of their own and never expired, and putting a single element could erase the TTL of its list. get("polls/99")for an id that is not cached calls the backend. 5.x returned the parent node instead.- Ids are compared by their string value.
"5"and5are now the same id, so you no longer get duplicate elements. putunder an id or index throws when the cached value is not an array. 5.x silently wrote properties onto the object.merge: trueno longer turns an array into an object.delete("posts/4711")works. 5.x threw an error for elements addressed by id.path2rest(path)works. 5.x returned wrong URL paths.
Populating-cache is heavily tested. Have a look at the JEST test cases and the tests for TTLs of collections. There you can also learn a lot about how to use populating-cache.
You can run all tests easily: npm test
Thanks to the creators of node-cache for coffeescript inspiration.
- PopulatingCache
- DEFAULT_CONFIG
Populating Cache
A lightweight client side cache that can store values in a tree structure. https://github.com/doogiemuc/populating-cache
configObject configuration options that override the DEFAULT_CONFIG, e.g.{ fetchFunc, ttl: 30000 }
Cache value under the given path in this cache. The value may then be retrieved back with a call to get(path).
All intermediate objects along the path will be created in the cache if necessary.
Then value is stored as the leaf at the end of path with the given ttl or the configured default ttl.
Collections (arrays) have a TTL of their own and one TTL per element:
put("polls", array)sets the TTL of the collection and of each of its elements.put("polls/5", poll),put("polls[2]", poll)orput("polls[]", poll)only set the TTL of that one element. The TTL of the collection stays untouched.
-
path(String | Array) path under which thevalueshall be stored in the cache -
valueAny The value to store in the cache. -
optionsObject Override default configuration options, e.g.ttlormerge -
Throws Error when path is invalid, when a scalar value is put under an id, when the id of value does not match the id in path or when an array element is put into something that is not an array.
Returns Object the cache instance, so that calls to put() can be chained: myCache.put("foo", "bar").put("foo2", "baz2")
Fetch a value from the cache. If the value isn't in the cache or if it is expired,
then the backend will be queried for the value under path.
When the backend is called, then the returned value will again be stored in the cache and its TTL will be updated.
The most specific TTL wins: An array element expires with its own TTL if it has one (e.g. from put("polls/5", poll)),
otherwise with the TTL of its collection (e.g. from put("polls", array)). The collection itself only expires with its own TTL.
With the option fetchCollection: true, a missing or expired element "polls/5" is not fetched on its own.
Instead fetchFunc is called for its whole collection ["polls"]. The returned array is put into the cache
and the element with the matching id is returned, or undefined when the new list does not contain it.
Get does not change the content of the cache at all. Specifically it does not remove expired elements.
You can call deleteExpiredElems() manually to cleanup the cache.
path(String | Array) array that forms the path to the value that shall be fetched. Each array element can be one of three formats: - a plain "attributeName" for object properties in the cache. - { arrayAttr: "abde3f"} for the array element with that id. - "array[index]" for array elements. Index must be a positive. For example: [{posts:5}, {comments:42}] is the comment with id 42 of the post with id 5 For a REST backend this can be translated to the REST resource at /posts/5/comments/42optionsObject Override default options, e.g. force call to backend, do not populate or fetchCollection.
Returns any (A Promise that resolves to) the fetched value. Either directly from the cache or from the backend.
This is a synchrounous version of get(). getSync() does not return a Promise. It returns the value at path directly if there is a valid value in the cache.
getSync() will not call the backend It can only return values that already are in the cache.
When there is a value in the cache, but it is expired, then there are two possible scenarios: 1. Simply return undefined to the caller. (This is the default) 2. Throw an Error. This way the caller can distinguish between "not in the cache at all" or "expired value at that path"
getSync() can do population, but only with values that already are in the cache.
-
optionsObject optionally override configuration options (callBackend and fetchCollection are ignored in this method.) -
throwWhenExpiredBoolean should the method throw when path points to an expired element. This way the caller can distinguish between "not in the cache at all" or "expired value at path". (optional, defaultfalse) -
Throws Error when an element along path is expired and throwWhenExpired = true. Otherwise undefined is returned for expired values. Also throws when path is invalid.
Returns any the value from the cache if there is one. (getSync() does not return a Promise, but the value itself.)
Fetch a value from the backend and put it into the cache. This will force a call to the backend, no matter if there alreay is a value in the cache. If your backend requires authentication, fetchFunc is responsible to handle that.
path(Array | String) path to value that you want to get from the cachefetchFuncFunction async function that will be called to fetch the value
Returns Promise resolves to the fetched value
Recursively populate all DBrefs in elem with a given property name, e.g.
populate all "createdBy" references (to "users") in an array of "posts":
populate(posts, "createdBy")
elem(Object | Array) an element from the cache that contains DBrefsrefPropString the name of the property that is a DBref and that shall be populatedoptionsObject configuration options (or will use defaults)
Returns Promise resolves to elem with its DBrefs replaced by the referenced values
Subscribe to changes at a given path (or below a path prefix)
The listener's onPut(path, value) method will be called, when a value is PUT into the cache. When a value is deleted, then onPut(path, undefined) is called. By default listeners are also called when a value is put below the path. This can be changed by passing exact = true. Then the listener is only notified, when the value at exactly this path is changed.
-
pathAny path prefix where we listen to changes. If undefined, then listener will be notified for every change of the cache. -
onPutFuncFunction callback function that will be notified when a value is put into the cache: onPutFunc(path, value) -
exactBoolean Notify listener only when the value at exactly this path is changed (default=false) (optional, defaultfalse) -
Throws Error when path is invalid
Returns Object the listener. Pass it to unsubscribe(listener) to stop listening.
Stop notifying a listener.
listenerObject the listener as returned bysubscribe()
Delete an element and its metadata from the cache.
If path points to an array element, e.g. "polls/5" or "polls[0]", then that array element and its entry in $elems
will be set to undefined, so that the length of the array does not change.
Listeners are notified with onPut(path, undefined).
-
path(String | Array) path to the element that shall be deleted -
Throws Error when path is invalid or ends in "array[]"
Check if a path points to a defined and not yet expired value in the cache.
If path points to an undefined value, then isInCache will return false.
This method will not change the content of the cache or metadata and will not call the backend at all.
(Use getSync(path) to fetch the value at a path.)
Returns Boolean true if there is a value !== undefined at that path and it is not yet expired.
Get (a direct reference!) to all the data in the cache.
The cacheData is exactly as returned by fetchFunc(path)
Returns Object the root of the cached data
While you put values into the cache, Populating-Cache automatically
creates a second parallel tree of metadata next to the cacheData.
The metadata contains the time to life (TTL) of the value in the cache.
ttl is the number of milliseconds since the epoc, when a value will expire.
The metadata of a collection (array) has its own ttl and type and an array $elems
with one metadata entry per element, index by index: { ttl, type, $elems: [ { _id, ttl, type }, … ] }.
The names ttl, type and $elems are reserved.
Returns the metadata node as it is stored, i.e. an element without an own entry or ttl has none here.
(get() then falls back to the ttl of the collection.)
path(String | Array) path to a specific element. If null or undefined, then the metadata of the whole cache is returned.
Returns Object metadata of cached value under path, e.g. { ttl: 55235325000, type: "number" }, or undefined
Completely empty the cache and delete all values in it. This also clears out all metadata.
Recursively walk through the cacheData and delete all expired elements. You may call this from time to time to optimize the cache's memory consumption.
Expired array elements and their entries in $elems are set to undefined, so that arrays keep their length.
When a collection is expired, then only its elements without a fresh TTL of their own are removed.
If none of them are left, then the collection is deleted completely.
Parse a path into a normalized array of { key, id, index, appendArray } objects.
Internally populating-cache uses these normalized pathes.
See README.md for a detailed description about pathes in populating-cache.
-
path(String | Array) plain string or array of path elements -
Throws Error when path or a path element is invalid
Returns Array Array of parsed objects { key, id, index, appendArray }. id or index may be undefind in each path element.
This is the inverse of parsePath(path). This method takes a parsedPathArray as input
and converts it back to a path as used by put, get and fetchFunc
parsedPathArrayArray parsedPath is an array of { id, key, index } objects as created by theparsePath(path)methodstartNumber start index in parsedPath Array to create sub pathes (optional, default0)endNumber end index (exclusive) for sub path (optional, defaultparsedPathArray.length)
Returns any the partial path recreated from parsedPathArray. Path elements in the form "key/id" are returned as {key:id}
Convert the given path to a REST URL path, e.g. [{posts: 11}, "comments"] to "/posts/11/comments".
-
pathArray a populating-cache path array -
Throws Error when path is not an array or contains an "array[index]" element
Returns String the REST URL path
Default configuration for a cache. You can overwrite these when creating a new PopulatingCache instance. Each cache instance can have its own configuration. See Configuration for a description of all options.