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 await when 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 hash exports are not cross-language portable. They use different stringifiers (JSON.stringify vs json.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’s hash only 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