@samveys/cache

Version
1.0.2Nov 5, 2026
Install
pnpm add @samveys/cache

From packages/cache/README.md in the framework repository.

Cache stores, cross-process locks and a concurrency funnel. No runtime dependencies.

StoreWhat it isUse for
memoryStore()A Map with expiry; values are structured-clonedTests, single process
databaseStore({ database, table? })A SQL table (key, JSON value, expires_at ms), created on first useShared state, cross-process locks
failoverStore(['a', 'b'])Tries stores in order; all failing is SVS_E097 (503)A fallback when a store is down

TTLs are in milliseconds. A missing TTL means "never expires".

Set up

ts
import { cache, databaseStore, defineCacheConfig, failoverStore, memoryStore } from '@samveys/cache'
import { createDatabaseManager, defineDatabaseConfig, sqlite } from '@samveys/database'

const db = createDatabaseManager(defineDatabaseConfig({ default: 'main', connections: { main: sqlite({ file: ':memory:' }) } }))

export const config = defineCacheConfig({
  default: 'shared',
  stores: {
    shared: failoverStore(['database', 'memory']),
    database: databaseStore({ database: () => db, table: 'samveys_cache' }),
    memory: memoryStore(),
  },
})

export const plugin = cache(config)

The plugin binds 'cache' (a CacheManager). Stores open on first use and close at shutdown.

Read, write, remember

ts
import { createCacheManager, defineCacheConfig, memoryStore } from '@samveys/cache'

const cache = createCacheManager(defineCacheConfig({ default: 'm', stores: { m: memoryStore() } }))

await cache.put('user:1', { name: 'Ana' }, 60_000)
const user = await cache.get<{ name: string }>('user:1')

// Computes only on a miss. Concurrent callers on a miss each compute.
const report = await cache.remember('report', 5 * 60_000, async () => ({ rows: 42 }))

// Atomic counter; the TTL applies when the counter is created (a fixed window).
const hits = await cache.increment('hits', 1, 60_000)
console.log(user?.name, report.rows, hits)

Locks

cache.lock(name, ttlMs) returns a handle with its own random owner token. release() only works for the handle that acquired the lock, so a slow job whose lock expired cannot free a newer owner's lock.

ts
import { createCacheManager, defineCacheConfig, memoryStore } from '@samveys/cache'

const cache = createCacheManager(defineCacheConfig({ default: 'm', stores: { m: memoryStore() } }))

const lock = cache.lock('nightly-report', 30_000)
if (await lock.acquire()) {
  try {
    // only one holder at a time
  } finally {
    await lock.release()
  }
}

// Or wait up to 5 s; throws SVS_E096 (409) when the wait runs out.
const other = cache.lock('nightly-report', 30_000)
await other.block(5_000)
await other.release()

With the database store, the lock is an INSERT into a primary-key column, so it holds across processes and servers that share the database: the second INSERT fails with a unique violation and acquire() returns false. A lock whose TTL passed is taken over by the next caller.

Funnel

A funnel limits how many callers run at once, across every process on the same store.

ts
import { createCacheManager, defineCacheConfig, memoryStore } from '@samveys/cache'

const cache = createCacheManager(defineCacheConfig({ default: 'm', stores: { m: memoryStore() } }))
const funnel = cache.funnel('thumbnails', { limit: 3, ttlMs: 60_000 })

// Waits up to 2 s for a free slot, else SVS_E096. The slot is freed even when the function throws.
const result = await funnel.run(async () => 'resized', { waitMs: 2_000 })
console.log(result)

ttlMs frees a slot whose holder crashed.

Notes

  • memoryStore() is per process: use databaseStore() for locks and counters that must be shared.
  • The database store keeps expired rows until they are read or replaced; delete old rows with a scheduled DELETE … WHERE expires_at < now.
  • Cache values must be JSON-compatible in the database store.