@nxtedition/cache
v2.0.1
Published
An async cache with SQLite persistence, in-memory LRU, stale-while-revalidate, and automatic request deduplication.
Keywords
Readme
@nxtedition/cache
An async cache with SQLite persistence, in-memory LRU, stale-while-revalidate, and automatic request deduplication.
Features
- Two-tier storage: In-memory LRU cache backed by SQLite on disk
- Stale-while-revalidate: Serve stale data while refreshing in the background
- Request deduplication: Concurrent fetches for the same key share a single in-flight request
- Async value resolution: Transparently fetches missing values via a user-defined
valueSelector - Buffer support: Store and retrieve binary data (Buffer, Uint8Array) alongside JSON values
- Size-bounded SQLite: Configurable max database size with automatic eviction of oldest entries
Usage
import { AsyncCache } from '@nxtedition/cache'
const cache = new AsyncCache(
'./my-cache.db', // SQLite file path, or ':memory:'
async (id: string) => {
// fetch the value for this key
const res = await fetch(`https://api.example.com/items/${id}`)
return res.json()
},
(id: string) => id, // keySelector: derive cache key from arguments
{
ttl: 60_000, // 60s before value is considered stale
stale: 30_000, // serve stale for 30s while revalidating
},
)
const result = cache.get('item-123')
if (result.async) {
// Cache miss — value is being fetched
const value = await result.value
} else {
// Cache hit — value returned synchronously
const value = result.value
}API
new AsyncCache(location, valueSelector, keySelector, opts?)
| Parameter | Type | Description |
| --------------- | ------------------------------ | --------------------------------------------- |
| location | string | SQLite database path, or ':memory:' |
| valueSelector | (...args) => V \| Promise<V> | Function to fetch a value on cache miss |
| keySelector | (...args) => string | Function to derive a cache key from arguments |
| opts | AsyncCacheOptions<V> | Optional configuration |
Options
| Option | Type | Default | Description |
| ---------- | ---------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
| ttl | number \| (value, key) => number | MAX_SAFE_INTEGER | Time-to-live in milliseconds. After this, the entry is stale. |
| stale | number \| (value, key) => number | MAX_SAFE_INTEGER | Stale-while-revalidate window in milliseconds. After ttl + stale, the entry is purged. |
| memory | MemoryOptions \| false \| null | { maxSize: 16MB, maxCount: 16384 } | In-memory cache options, or false/null to disable in-memory caching. |
| database | DatabaseOptions \| false \| null | { timeout: 20, maxSize: 256MB } | SQLite options, or false/null to disable persistence. |
MemoryOptions
| Option | Type | Default | Description |
| ---------- | -------- | -------------------------- | ---------------------------------------------- |
| maxSize | number | 16 * 1024 * 1024 (16 MB) | Maximum total size in bytes of cached entries. |
| maxCount | number | 16 * 1024 (16384) | Maximum number of entries in memory. |
DatabaseOptions
| Option | Type | Default | Description |
| --------- | -------- | ---------------------------- | ----------------------------------------------------------------- |
| timeout | number | 20 | SQLite busy timeout in milliseconds. |
| maxSize | number | 256 * 1024 * 1024 (256 MB) | Maximum database file size. Oldest entries are evicted when full. |
CacheResult<V>
Both get() and peek() return a CacheResult<V>, a discriminated union on the async property:
| async | value | Meaning |
| ------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| false | V \| undefined | Cache hit — the value is available synchronously. Also returned for stale entries (a background refresh is triggered automatically). undefined when peek() has no cached entry. |
| true | Promise<V> | Cache miss — value is a Promise that resolves when the valueSelector completes. |
const result = cache.get('key')
if (result.async) {
// miss — await the fetch
const value = await result.value
} else {
// hit (fresh or stale) — use directly
const value = result.value
}Methods
cache.get(...args): CacheResult<V>
Returns a cached value or triggers a fetch on cache miss.
cache.peek(...args): CacheResult<V>
Same as get() but does not trigger a refresh on cache miss. Returns { value: undefined, async: false } for missing entries.
cache.refresh(...args): Promise<V>
Triggers a fetch via valueSelector regardless of cache state. If a fetch for the same key is already in-flight (from a prior get() or refresh()), the existing promise is returned instead of starting a new one.
cache.delete(...args): void
Remove an entry from the cache. The cache key is derived from args via the keySelector, just like get() and refresh(). Also cancels any in-flight deduplication for that key, meaning a pending fetch will not write its result to the cache.
cache.purgeStale(): void
Remove all expired entries from both the in-memory cache and SQLite.
cache.close(): void
Close the SQLite database and release resources.
Deduplication
Concurrent calls to get() or refresh() for the same key share a single in-flight Promise. The valueSelector is called only once, and all callers receive the same resolved value.
// valueSelector is called once, both promises resolve to the same value
const [a, b] = await Promise.all([cache.get('key').value, cache.get('key').value])If a fetch fails, the deduplication entry is cleaned up and subsequent calls will retry.
Calling cache.delete(key) while a fetch is in-flight invalidates the deduplication entry. The pending promise still resolves for its callers, but the result is not written to the cache.
Stale-While-Revalidate
When an entry's TTL has expired but is still within the stale window (ttl + stale), get() returns the stale value synchronously (async: false) while triggering a background refresh.
Once the stale window expires, the entry is purged entirely and the next get() returns async: true.
|--- ttl ---|--- stale ---|
fresh stale expiredOff-Peak Purge
All cache instances listen for messages on the nxt:offPeak BroadcastChannel. When a message is received, purgeStale() is called on every active instance, allowing coordinated cleanup during low-traffic periods.
Scripts
npm test # run tests
npm run test:coverage # run tests with branch coverage report (90%+ enforced)
npm run typecheck # type-check without emitting
npm run build # build for publishing