Skip to content

Commit

Permalink
✨ Added support for multiple resolve and revalidate layers
Browse files Browse the repository at this point in the history
  • Loading branch information
juriadams committed Sep 6, 2023
1 parent 808e145 commit 50a5e2a
Show file tree
Hide file tree
Showing 2 changed files with 259 additions and 44 deletions.
203 changes: 162 additions & 41 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
/**
* A function used to resolve a cache miss.
*/
type Resolver<K, V> = (
/**
* The key of the resource that has been requested.
*/
key: K
) => Promise<V | undefined>;

type Revalidator<K, V> = (
/**
* The key of the resource that has been requested.
*/
key: K,
/**
* If present, the stale value that is currently cached.
*/
staleValue: V | undefined
) => Promise<V | undefined>;

interface CacheOptions<K, V> {
/**
* The time to live (TTL) of items in the cache in milliseconds. If an item
Expand Down Expand Up @@ -30,37 +51,64 @@ interface CacheOptions<K, V> {
/**
* If given, this function is used to resolve items for cache misses.
*
* This is useful when caching third party API calls where you always
* expect a value to be returned.
* ```
* const cache = new Cache<string, string>({
* resolve: async (key) => fetch(`https://api.acme.co/${key}`);
* });
* ```
*
* If an array of functions is given, they will be called in order until
* a value is returned.
*
* Promise rejections are handled gracefully and will not throw an error.
* Instead, `undefined` will be returned.
* ```
* const cache = new Cache<string, string>({
* resolve: [
* async (key) => KV.get(key),
* async (key) => fetch(`https://api.acme.co/${key}`),
* ]
* });
* ```
*
* @param key The key of the resource to resolve.
* @returns The value to store in the cache.
* If all functions reject or return `undefined`, the cache miss will be
* handled gracefully and `undefined` will be returned.
*
* This is useful when caching third party API calls where you always
* expect a value to be returned.
*/
resolve?: (key: K) => Promise<V | undefined>;
resolve?: Resolver<K, V> | Array<Resolver<K, V>>;

/**
* If given, this function is called every time an item is retrieved from
* the cache. If it returns a value, that value is written to the cache.
* If given, this function is used to revalidate items either every time
* they have been retrieved from the cache (`revalidateOnGet = true`) or
* when `Cache.revalidate` is called.
*
* This is useful when using a cache with a long TTL, but you want to verify
* the validity of the cached value after retrieved.
* ```
* const cache = new Cache<string, string>({
* revalidate: async (key) => fetch(`https://api.acme.co/${key}`);
* });
* ```
*
* If an array of functions is given, they will be called in order until
* a value is returned.
*
* One example would be a cache of user permissions. Here, the first hit
* would return stale data that is then revalidated in the background.
* If deemed invalid (`undefined` is returned), the item will be deleted
* from the cache.
* ```
* const cache = new Cache<string, string>({
* revalidate: [
* async (key) => KV.get(key),
* async (key) => fetch(`https://api.acme.co/${key}`),
* ]
* });
* ```
*
* Promise rejections are handled gracefully and will not throw an error.
* Instead, the item will be deleted from the cache.
* While revalidation is pending, stale data is returned.
*
* @param key The key of the resource that has been retrieved.
* @returns The value to write to the cache, or `undefined` to delete the
* item from the cache.
* If all functions reject or return `undefined`, the item will be evicted
* from the cache as it does no longer exist or is invalid.
*
* This is useful when using a cache with a long TTL, but you want to verify
* the validity of the cached values after they are retrieved.
*/
revalidate?: (key: K) => Promise<V | undefined>;
revalidate?: Revalidator<K, V> | Array<Revalidator<K, V>>;
}

interface CacheItem<V> {
Expand All @@ -69,6 +117,26 @@ interface CacheItem<V> {
value: V;
}

interface RevalidateOptions<K, V> {
/**
* See `CacheOptions.revalidate`.
*
* Useful when the `revalidate` function has one ore more dependencies
* that are not available when constructing the cache.
*/
revalidate: CacheOptions<K, V>["revalidate"];
}

interface ResolveOptions<K, V> {
/**
* See `CacheOptions.resolve`.
*
* Useful when the `resolve` function has one ore more dependencies
* that are not available when constructing the cache.
*/
resolve: CacheOptions<K, V>["resolve"];
}

export class Cache<K, V> {
private readonly cache = new Map<K, CacheItem<V>>();

Expand All @@ -77,8 +145,8 @@ export class Cache<K, V> {
public readonly resetTtlOnGet: CacheOptions<K, V>["resetTtlOnGet"];
public readonly revalidateOnGet: CacheOptions<K, V>["revalidateOnGet"];

private readonly resolve: CacheOptions<K, V>["resolve"];
private readonly revalidateFunction: CacheOptions<K, V>["revalidate"];
private readonly resolvers = new Array<Resolver<K, V>>();
private readonly revalidators = new Array<Revalidator<K, V>>();

/**
* The number of items currently inside the cache. This includes items
Expand All @@ -93,18 +161,56 @@ export class Cache<K, V> {
this.maxSize = options.maxSize;
this.resetTtlOnGet = options.resetTtlOnGet;
this.revalidateOnGet = options.revalidateOnGet;
this.resolve = options.resolve;
this.revalidateFunction = options.revalidate;

if (options.resolve) {
if (Array.isArray(options.resolve))
this.resolvers.push(...options.resolve);
else this.resolvers.push(options.resolve);
}

if (options.revalidate) {
if (Array.isArray(options.revalidate))
this.revalidators.push(...options.revalidate);
else this.revalidators.push(options.revalidate);
}
}

public readonly revalidate = async (key: K) => {
if (!this.revalidateFunction)
/**
* If called, revalidates the value of the given key using either the
* `revalidate` function(s) provided in the constructor or the one(s)
* passed to this function.
*
* ```
* const cache = new Cache<string, string>({
* revalidate: async (key) => fetch(`https://api.acme.co/${key}`),
* });
*
* await cache.revalidate("foo");
* ```
*
* @param key They key of the value to revalidate.
* @param options Options for revalidation, see `RevalidateOptions`.
*/
public readonly revalidate = async (
key: K,
options?: RevalidateOptions<K, V>
): Promise<void> => {
const revalidators = options?.revalidate
? Array.isArray(options.revalidate)
? options.revalidate
: [options.revalidate]
: this.revalidators;

if (!revalidators.length)
throw new Error(
"When calling `Cache.revalidate`, a `revalidate` function must be provided in the constructor."
"When calling `Cache.revalidate`, at least one `revalidate` function must be provided in the constructor or passed when calling `Cache.revalidate`."
);

// TODO: Don't silently handle errors.
const value = await this.revalidateFunction(key).catch(() => undefined);
let value: V | undefined = undefined;
for (const revalidator of revalidators) {
value = await revalidator(key, value).catch(() => undefined);
if (value) break;
}

// If a value was returned, update the cache.
if (value) this.set(key, value);
Expand All @@ -114,7 +220,7 @@ export class Cache<K, V> {
};

/**
* Set or overwrite value in the cache by its key.
* Set or overwrite a cached value by its key.
* @param key The key under which to store the value.
* @param value The value to store.
* @returns `CacheItem` containing the stored value.
Expand Down Expand Up @@ -154,32 +260,47 @@ export class Cache<K, V> {
* will be resolved via the provded function and stored inside the cache.
*
* @param key The key of the value to retrieve.
* @param options Options for revalidation, see `ResolveOptions`.
* @returns The resolved value or undefined.
*/
public readonly get = async (key: K) => {
public readonly get = async (key: K, options?: ResolveOptions<K, V>) => {
let hit: CacheItem<V> | undefined = undefined;
hit = this.cache.get(key);

// Purge the item from cache if it's stale.
// Purge the item from cache if its TTL has expired.
if (hit && hit.start + hit.ttl < new Date().getTime()) {
this.delete(key);
hit = undefined;
}

// If a resolver is provided, use it to fetch the value.
if (!hit && this.resolve) {
// TODO: Don't silently handle errors.
const value = await this.resolve(key).catch(() => undefined);
if (value) hit = this.set(key, value);
const resolvers = options?.resolve
? Array.isArray(options.resolve)
? options.resolve
: [options.resolve]
: this.resolvers;

// If the cache is missed and one or more `resolve` functions are
// provided, resolve the value and store it inside the cache.
if (!hit && resolvers.length) {
for (const resolver of resolvers) {
const value = await resolver(key).catch(() => undefined);
if (value) {
hit = this.set(key, value);
break;
}
}
}

if (hit && this.resetTtlOnGet) {
this.set(key, hit.value);
}

if (hit && this.revalidateOnGet) {
// We are not using `await` here because we don't want to block
// the request while the cache is revalidated.
// If the cache is hit and `revalidateOnGet` is set, revalidate the
// value. Values resolved by a `resolve` function are not revalidated
// as they are assumed to be fresh.
if (hit && this.revalidateOnGet && !resolvers.length) {
// We are not using `await` here because we don't want to delay
// the response while the cache is being revalidated.
//
// IMPORTANT: When running inside a worker, the thread might be
// terminated before the revalidation is complete. In this case,
Expand Down
Loading

0 comments on commit 50a5e2a

Please sign in to comment.