@johngw/stream
Version:
Reactive programming tools using the WHATWG Streams API.
149 lines (133 loc) • 3.15 kB
text/typescript
import { isNonNullObject } from '@johngw/stream-common/Object'
/**
* Timed cache using a `Storage` interface.
*
* @group Storages
* @example
* Letting cache expire.
*
* ```
* const cache = new StorageCache(localStorage, 'my-cache', 30 * 60_000)
*
* cache.set(['a', 'path'], 'foo')
* cache.get(['a', 'path'])
* // 'foo'
*
* await setTimeout(30 * 60_000)
* cache.get(['a', 'path'])
* // undefined
* ```
* @example
* Manually invalidating cache.
*
* ```
* const cache = new StorageCache(localStorage, 'my-cache', 30 * 60_000)
* cache.set(['foo', 'bar'], 'a')
* cache.set(['foo', 'rab'], 'b')
*
* // Remove a specific cache item
* cache.unset(['foo', 'bar'])
*
* // Remove all "foos"
* cache.unset(['foo'])
* ```
*/
export class StorageCache {
#storage: Storage
#key: string
#ms: number
constructor(storage: Storage, key: string, ms: number) {
this.#storage = storage
this.#key = key
this.#ms = ms
}
get ms() {
return this.#ms
}
/**
* Ads or overrides an item in the cache.
*/
set(path: string[], value: unknown, ms = this.#ms) {
this.#save({
...this.getAll(),
[this.#pathKey(path)]: {
t: Date.now() + ms,
v: value,
},
})
setTimeout(() => {
this.unset(path)
}, ms)
}
/**
* Removes one or more items from the cache using the path
* as key hierarchy.
*/
unset(path: string[]) {
const pathKey = this.#pathKey(path)
this.#save(
Object.entries(this.getAll()).reduce(
(acc, [key, value]) =>
this.#keyMatches(pathKey, key) ? acc : { ...acc, [key]: value },
{}
)
)
}
/**
* Returns the cached value. Returns undefined if it doesn't
* exist or if the cache is stale.
*/
get(path: string[]) {
return this.#get(path)?.v
}
/**
* Checks if a path has cache. If not, updates it and returns `true`.
* Otherwise returns `false`.
*/
async updateIfStale(
path: string[],
update: () => unknown | Promise<unknown>,
ms?: number
) {
let item = this.get(path)
if (item === undefined) {
item = await update()
if (item === undefined) return false
this.set(path, item, ms)
return true
}
return false
}
/**
* Returns the remaining cache time for an item.
*/
timeLeft(path: string[]) {
const now = Date.now()
const time = this.#get(path)?.t || now
return Math.max(time - now, 0)
}
#pathKey(path: string[]) {
return path.join('.')
}
#keyMatches(pathKey: string, test: string) {
return test === pathKey || test.startsWith(`${pathKey}.`)
}
#get(path: string[]) {
const result = this.getAll()[this.#pathKey(path)]
if (!isStoredItem(result)) return undefined
return result
}
#save(obj: Record<string, unknown>) {
this.#storage.setItem(this.#key, JSON.stringify(obj))
}
getAll() {
return JSON.parse(this.#storage.getItem(this.#key) || '{}')
}
}
interface StoredItem<T> {
t: number
v: T
}
function isStoredItem<T>(x: unknown): x is StoredItem<T> {
return isNonNullObject(x) && typeof x.t === 'number' && 'v' in x
}