Skip to content

Instantly share code, notes, and snippets.

@sonhanguyen
Last active September 24, 2024 00:17
Show Gist options
  • Save sonhanguyen/4743d4b7c479d52c43a5447f831271ee to your computer and use it in GitHub Desktop.
Save sonhanguyen/4743d4b7c479d52c43a5447f831271ee to your computer and use it in GitHub Desktop.
File system-based memoization
/**
* @template [T=any]
* @property {{ (_: T): Promise<void>} & { pending?: Promise<T> }} put
*/
class CacheIOError extends Error {
constructor(cause, context) {
Object.assign(this, context, { cause })
}
}
const { access, readFile, writeFile, constants, mkdir } = require('fs')
const { promisify } = require('util')
const path = require('path')
/**
* @template V
* @template [K=string]
* @typedef {{
* has(_: K) => Promise<boolean>
* get(_: K): Promise<V>
* set(..._: [K, V]): Promise<void>
* }} Storage
*/
/**
* @template {any} T
* @arg {{
* serialize(_: T) => string;
* deserialize(_: string) => T;
* ext?: string
* dir?: string
* }}
* @return {Storage<T>}
*/
const fileStorage = ({
deserialize,
serialize,
dir = '.',
ext
} = {
serialize: it => JSON.stringify(it, null, 2),
deserialize: JSON.parse,
ext: 'json'
}) => {
const existsFile = (path, cb) =>
access(path, constants.R_OK, error => cb(undefined, !error))
const storage = Object.fromEntries(
[ existsFile, readFile, writeFile ].map(func => [
func.name.replace(/File$/, ''),
promisify((key, ...args) => {
if (ext) key += `.${ext}`
return func(path.join(dir, key), ...args)
})
])
)
return {
has: storage.exists,
async get (key) {
return deserialize(await storage.read(key))
},
async set (key, value) {
const bucket = path.dirname(key)
if (!await storage.exists(bucket)) await promisify(mkdir)(
path.join(dir, bucket),
{ recursive: true }
)
return storage.write(key, serialize(value))
}
}
}
const lazy = func => {
const get = () => {
if (!('cached' in get)) get.cached = func()
return get.cached
}
return get
}
/**
* @template [T=any]
* @template [P=any[]]
* @typedef {(..._: P) => Promise<T>} Async<T, P>
*/
/**
* @template {any[]} P
* @template T
* @typedef {{
* invalidate?(_: P & {
* cached(): Promise<T>
* key: string
* }): | boolean | void | Promise<boolean>
* getErrorFallback?: false | {
* (..._: [any, { key: string }]): boolean | Promise<T>
* }
* name?: string
* cache?: Storage<T>
* cacheId?(..._: P): string
* }} CacheOptions<P, T>
*/
/**
* @template {Async} F
* @arg {F | CacheOptions<
* Parameters<F>,
* F extends Async<T> ? T : never>
* } options
* @arg {F} target
* Given a taget function, return a it memoized
* @return {F}
*/
const withCache = (options, target = options) => async (...context) => {
const {
invalidate,
// by default, `CacheIOError` only results in unhandled promise rejection,
// for that we avoid changing the semantic of the target function
// if `getErrorFallback` resolves to `false`, cache.get error will throw, but not cache.set
getErrorFallback,
name = target.name,
cache = fileStorage(),
cacheId = args => path.join(name, JSON
.stringify(args)
.replace(/"[^"]+":/g, '') // remove keys
.replace(/[^\w]+/g, '-')
.replace(/^-+|-+$/g, '')
.substr(0, 30) // make it long enough to be unique
)
} = options
const key = cacheId(context, target)
const put = async promise => cache.set(key, await promise)
Object.assign(context, {
put, key, cache, target, name,
cached: lazy(async() => {
try { return await cache.get(key) }
catch(error) {
let value = getErrorFallback?.(error, context)
if (value?.then) return value
// only raise exception if the callback option resolves to false
if ([getErrorFallback, value].includes(false)) raise(error)
Promise.reject(new CacheIOError(error))
// fallback to call() the target function and throw UnhandledPromiseRejectionWarning
return context.call()
}
}),
call: () => target(...context)
})
const raise = (error, pending) => {
if (pending?.then) Object.assign(put, { pending })
throw new CacheIOError(error, context)
}
invalidate = invalidate?.(context)
if (invalidate?.then) invalidate = await invalidate
if (invalidate || !await cache.has(key)) {
const promise = context.call()
put(promise).catch(error => {
/** intentionally uncaught, not to fail the call just because of put.pending error
* @see https://nodejs.org/api/process.html#event-unhandledrejection */
raise(error, promise)
})
return promise // also NOT await for the cache set operation
}
return await context.cached()
}
module.exports = {
cached: withCache,
CacheIOError,
fileStorage,
lazy,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment