Rendered docs: thekevinscott.github.io/cachetta/javascript
Cachetta for TypeScript
File-based caching for TypeScript. Uses v8.serialize for native binary serialization – any file extension works, and all V8-serializable types (Maps, Sets, Dates, Buffers, typed arrays, RegExps, etc.) are supported natively.
Install
pnpm add cachetta
Basic Usage
Create a cache object:
import { Cachetta } from 'cachetta';
const cache = new Cachetta({
read: true,
write: true,
path: './cache.json',
duration: 24 * 60 * 60 * 1000, // 1 day in milliseconds
});
Read and write:
import { readCache, writeCache } from 'cachetta';
async function getData() {
const cachedData = await readCache(cache);
if (cachedData) {
return cachedData;
}
const data = await fetchData();
await writeCache(cache, data);
return data;
}
Decorators
Use Cachetta as a decorator (requires experimental decorators):
import { Cachetta } from 'cachetta';
class DataService {
@Cachetta({ path: '/my-cache.json' })
async getData() {
return await fetchData();
}
}
With a specific cache object:
const cache = new Cachetta({ path: '/my-cache.json' });
class DataService {
@cache
async getData() {
return await fetchData();
}
}
Or with overrides:
const cache = new Cachetta({ path: '/my-cache.json' });
class DataService {
@cache({ duration: 1000 })
async getData() {
return await fetchData();
}
}
Decorated functions always return Promises, even if the original function is synchronous. Always use
awaitwhen calling decorated functions.
Function Wrapper
If you’re not using decorators, wrap functions manually:
const cache = new Cachetta({ path: './my-cache.json' });
const cachedGetData = cache(async () => {
return await fetchData();
});
const result = await cachedGetData();
With configuration:
const cache = new Cachetta({ path: './cache' });
const cachedGetData = cache(getData, {
path: (id) => `./cache/data-${id}.json`,
duration: 5000
});
const result = await cachedGetData(123);
Sync API
All methods have synchronous counterparts:
import { Cachetta, writeCacheSync, readCacheSync } from 'cachetta';
const cache = new Cachetta({ path: './cache.json' });
// Sync read/write
writeCacheSync(cache, { data: 1 });
const data = readCacheSync(cache);
// Sync inspection
cache.existsSync();
cache.ageSync();
cache.infoSync();
// Sync invalidation
cache.invalidateSync();
// Sync function wrapping
const cachedFn = cache.wrapSync(() => computeExpensiveValue());
const result = cachedFn();
Per-Argument Cache Files
A string path is used verbatim — every call writes to the same file regardless of arguments. To key cache files by argument, pass path as a function that receives the wrapped function’s arguments:
const cache = new Cachetta({ path: (userId) => `./cache/users/${userId}.json` });
const getUser = cache((userId) => fetchUser(userId));
await getUser(1); // cached at ./cache/users/1.json
await getUser(2); // cached at ./cache/users/2.json
Hashed mode
When you want one file per arg-set inside a folder (the common LLM / embedding cache shape), set hashed: true. The path you pass is treated as a directory, and entries are written as {path}/{hash(...args)}:
const cache = new Cachetta({ path: './cache/llm', hashed: true });
const call = cache((prompt) => llm(prompt));
await call('hello'); // ./cache/llm/<hash>
await call('world'); // ./cache/llm/<otherhash>
hashed is a regular field on CacheConfig, so it works at every entrypoint:
// Constructor
const cache = new Cachetta({ path: './cache', hashed: true });
// Per-wrap override (creates an isolated copy, base cache is not mutated)
const cached = baseCache(fn, { hashed: true });
// Copy
const hashedCache = baseCache.copy({ hashed: true });
If path is a callable, it picks the folder and the hash names the file within it — the “shard by one arg, hash by all” pattern:
const cache = new Cachetta({
path: (model, prompt) => `./cache/${model}`,
hashed: true,
});
const callLLM = cache(async (model, prompt) => callApi(model, prompt));
await callLLM('gpt', 'hi'); // ./cache/gpt/<hash('gpt', 'hi')>
await callLLM('claude', 'hi'); // ./cache/claude/<hash('claude', 'hi')>
hashed composes with condition.
Public hash helper
The same digest the auto-keyed path uses is exposed as a top-level hash export. Use it when you want to construct cache paths manually (e.g. inside a path: callable that keys on a subset of args) and keep them aligned with cachetta’s own keying:
import { Cachetta, hash } from 'cachetta';
const cache = new Cachetta({
path: (model, prompt, opts) => `./cache/llm/${model}/${hash(prompt)}.json`,
});
const callLLM = cache(async (model, prompt, opts) => callApi(model, prompt, opts));
hash(...args) accepts any JSON-serializable arguments and returns a 16-char hex string. It’s a pure function — no I/O, no Cachetta instance required.
The JS and Python
hashexports are not cross-language portable. They use different stringifiers (JSON.stringifyvsjson.dumps(..., default=str)) and the Python variant also folds in**kwargs, so the same logical input produces different digests in each language. Use each language’shashonly to align with that language’s own cachetta.
Conditional Caching
Cache results only when a condition function returns true:
const cache = new Cachetta({
path: './cache.json',
condition: (result) => result !== null,
});
Caching null and undefined
A wrapped function that legitimately returns null or undefined is cached like any other value — the cached result is served on subsequent calls instead of re-running the function:
const cache = new Cachetta({ path: './cache.json' });
let calls = 0;
const cachedLookup = cache(async (id) => {
calls++;
return await lookupThatCanReturnNull(id); // e.g. returns null
});
await cachedLookup('missing-id'); // calls === 1, returns null
await cachedLookup('missing-id'); // calls still 1 (cache hit), returns null
Internally, “no cached value” (file absent, or read: false) and “cached value is null/undefined” are distinguished via a dedicated miss sentinel, so a stored nullish value is never mistaken for a cache miss. If you want a null/undefined result to be treated as not worth caching, use condition (see Conditional Caching).
Stale-While-Revalidate
Return expired data immediately while refreshing in the background:
const cache = new Cachetta({
path: './cache.json',
duration: 60 * 60 * 1000, // 1 hour
staleDuration: 30 * 60 * 1000, // serve stale up to 30min past expiry
});
Computation is single-flight per cache key within a wrapped function: a background refresh and a direct (cache-miss) call never run the underlying function concurrently. A caller that misses the cache while a background refresh is already running awaits that refresh and receives its result — including its error, if the refresh fails.
Cache Invalidation
const cache = new Cachetta({ path: './cache.json' });
await cache.invalidate(); // delete the resolved cache file unconditionally
cache.invalidateSync(); // sync variant
// With arguments (when using path functions)
await cache.invalidate('userId');
Clearing the Cache
clear sweeps whatever the instance’s path resolves to — a single file, or a whole folder (walked recursively; directories are kept). Without options it deletes only entries that are no longer servable: age ≥ duration, plus staleDuration when configured, so entries still inside the stale-while-revalidate window are kept.
const cache = new Cachetta({
path: './cache',
hashed: true,
duration: 60 * 60 * 1000,
});
await cache.clear(); // delete dead entries, keep fresh/stale ones
await cache.clear({ force: true }); // remove the whole path, folder and all
cache.clearSync(); // sync variants
cache.clearSync({ force: true });
// With arguments (when using path functions) — options always come last
await cache.clear('userId', { force: true });
Both methods return nothing. force skips the walk entirely and removes the resolved path wholesale (fs.rm recursive), so wiping a large cache costs one syscall rather than a traversal of every entry; the folder is re-created on the next write. A missing path is a no-op.
Note the options object is recognized only when it is exactly { force: boolean }, so a trailing cache arg is never mistaken for options.
Cache Inspection
Query cache state without reading the cached data:
const cache = new Cachetta({ path: './cache.json' });
await cache.exists(); // true if the cache file exists
await cache.age(); // age in milliseconds, or null
await cache.info(); // { exists, age, expired, stale, path }
// Sync variants
cache.existsSync();
cache.ageSync();
cache.infoSync();
Dynamic Cache Paths
Specify a function for defining the path:
function getCachePath(n) {
return `./cache/${n}.json`;
}
@Cachetta({ path: getCachePath })
async function foo(n) {
return computeExpensiveValue(n);
}
Specifying Paths
Use copy to create variations of a cache configuration:
const cache = new Cachetta({ path: './cache' });
const newCache = cache.copy({
read: false,
duration: 2 * 24 * 60 * 60 * 1000,
});
Path Contract
path (whether a literal string or a PathFn) is used exactly as given — resolved, then read, written, or unlinked. Cachetta does not sandbox, canonicalize symlinks, or reject absolute paths or .. segments; it trusts the path as developer-supplied configuration.
Never build a path (or the arguments passed to a PathFn) from untrusted input — user-controlled strings, request bodies, etc. A path derived from untrusted data is a write/delete-anywhere primitive: cachetta will read, overwrite, or unlink whatever the resolved path points to, including outside the intended cache directory. Keep paths static, derived from trusted application state (config, internal IDs), or hashed via the built-in hash helper.
Error Handling
Cachetta gracefully handles corrupt cache files by returning null:
const cache = new Cachetta({ path: './cache.json' });
const data = await readCache(cache);
if (data === null) {
// Cache is missing or corrupt
const freshData = await fetchFreshData();
await writeCache(cache, freshData);
}
Logging
import { setLogLevel, setLogger } from 'cachetta';
// Enable debug logging
setLogLevel('debug'); // 'error', 'warn', 'info', 'debug'
// Or use a custom logger
setLogger({
debug: (msg) => myLogger.debug(msg),
info: (msg) => myLogger.info(msg),
warn: (msg) => myLogger.warn(msg),
error: (msg) => myLogger.error(msg),
});
Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
path | string \| Function | required | Cache file path or path function |
hashed | boolean | false | Treat path as a folder; write one file per arg-hash inside it |
read | boolean | true | Allow reading from cache |
write | boolean | true | Allow writing to cache |
duration | number | 7 days (ms) | Cache TTL in milliseconds |
condition | Function | undefined | Predicate to decide whether to cache |
staleDuration | number | undefined | Time past expiry to serve stale data |