js/cache

js/cache.ts

fino:cache — small application cache abstraction with memory, SQLite, and HTTP helpers.

This module provides local cache primitives for application code that needs short-lived reuse without committing to a distributed cache contract. Cache entries are scoped by namespace, expire by millisecond TTL, and can be invalidated through tags. Values are serialized as JSON so the memory and SQLite backends share one observable value model.

Design

A Cache is a simple async key/value interface. memoryCache() is an LRU cache intended for tests and single-process applications; sqliteCache() persists entries through fino:database/sqlite and the configured filesystem provider. responseCache() is a layer for fino:net/http/app and caches safe GET/HEAD responses by method, URL, and configured vary headers, stamping each response with an x-fino-cache diagnostic header.

Expiry is lazy: expired entries are removed by the read that observes them, not by a background sweeper.

This is deliberately not a distributed systems layer. There is no cross- process invalidation for memory caches, no cluster coherence, and no binary structured-clone value support in this baseline.

import { memoryCache, responseCache } from 'fino:cache';
import { App } from 'fino:net/http/app';

const cache = memoryCache({ maxEntries: 1_000 });
await cache.set('user:1', { name: 'Ada' }, { ttlMs: 60_000, tags: ['users'] });
const user = await cache.get('user:1');

const app = new App().layer(responseCache(cache, { ttlMs: 5_000 }));

Interfaces

interface CacheClock {

Clock used by cache backends to decide when entries expire.

Both memoryCache() and sqliteCache() read the clock on every operation and compare it against each entry's stored expiry. Supplying a fake clock makes TTL behavior deterministic in tests: advance the fake time instead of sleeping.

import { memoryCache, type CacheClock } from 'fino:cache';

let now = 0;
const clock: CacheClock = { now: () => now };
const cache = memoryCache({ clock });

await cache.set('k', 'v', { ttlMs: 50 });
now += 51;
await cache.get('k'); // null — expired without waiting

Methods

now(): number

Return the current time in milliseconds.

interface CacheSetOptions {

Options applied when writing a cache entry with Cache.set().

Omitting ttlMs stores the entry without an expiry. A ttlMs of 0 (or a negative value, which is clamped to 0) produces an entry that is already expired on the next read. Tags are remembered per entry and matched later by invalidateTags(); writing a key again replaces its previous tags entirely.

import { memoryCache } from 'fino:cache';

const cache = memoryCache();
await cache.set('user:1', { name: 'Ada' }, { ttlMs: 60_000, tags: ['users'] });
await cache.invalidateTags(['users']); // removes user:1

Properties

ttlMs?: number

Milliseconds until the entry expires. Omit for no expiry.

tags?: string[]

Tags used by invalidateTags() to remove related entries.

interface Cache {

Small async cache interface shared by all backends.

Values round-trip through JSON, so only JSON-serializable data survives a set()/get() cycle, and reads return a fresh deserialized copy rather than the object that was stored. get() returns null for missing or expired entries; the read that observes an expired entry also deletes it.

Every operation is scoped to the cache's current namespace. namespace() returns a view over the same backend with a different namespace, so keys do not collide across namespaces and tag invalidation only affects the namespace it is called on.

import { memoryCache, type Cache } from 'fino:cache';

const cache: Cache = memoryCache();
await cache.set('config', { theme: 'dark' }, { tags: ['settings'] });

const tenant = cache.namespace('tenant-42');
await tenant.set('config', { theme: 'light' });

await cache.get('config');  // { theme: 'dark' }
await tenant.get('config'); // { theme: 'light' }

await cache.invalidateTags(['settings']); // leaves tenant-42 untouched

Methods

get<T = unknown>(key: string): Promise<T | null>

Read key, returning null when the entry is missing or expired.

set<T = unknown>(key: string, value: T, opts?: CacheSetOptions): Promise<void>

Store value under key, replacing any previous value in this namespace.

delete(key: string): Promise<void>

Delete key from this namespace. Missing keys are ignored.

invalidateTags(tags: string[]): Promise<void>

Delete entries in this namespace that have any of the supplied tags.

namespace(name: string): Cache

Return a view over the same backend using name as its namespace.

interface MemoryCacheOptions {

Options for memoryCache().

import { memoryCache } from 'fino:cache';

const cache = memoryCache({
  maxEntries: 10_000,
  namespace: 'sessions'
});

Properties

maxEntries?: number

Maximum retained entries across all namespaces. Defaults to unlimited.

namespace?: string

Initial namespace. Defaults to "default".

clock?: CacheClock

Clock used for TTL checks. Defaults to Date.now().

interface SqliteCacheOptions {

Options for sqliteCache().

import { sqliteCache } from 'fino:cache';
import { DiskFileSystem } from 'fino:file';

const cache = await sqliteCache({
  path: '/var/lib/myapp/cache.db',
  namespace: 'render',
  fs: new DiskFileSystem()
});

Properties

path: string

SQLite database path.

namespace?: string

Initial namespace. Defaults to "default".

clock?: CacheClock

Clock used for TTL checks. Defaults to Date.now().

fs?: FileSystem

Optional filesystem provider for the SQLite VFS.

interface SqliteCache extends Cache {

SQLite-backed cache handle returned by sqliteCache().

Adds close() on top of the Cache interface. Only the handle returned by sqliteCache() owns the database connection; namespace views created with namespace() share it without owning it, so closing the owning handle ends access for every view derived from it.

import { sqliteCache } from 'fino:cache';

const cache = await sqliteCache({ path: '/tmp/app-cache.db' });
try {
  await cache.set('greeting', 'hello', { ttlMs: 60_000 });
} finally {
  await cache.close();
}

Methods

close(): Promise<void>

Close the underlying SQLite database.

interface ResponseCacheOptions {

Options for responseCache().

import { memoryCache, responseCache } from 'fino:cache';

const middleware = responseCache(memoryCache(), {
  ttlMs: 5_000,
  vary: ['accept-language'],
  statuses: [200, 404],
  header: 'x-cache'
});

Properties

ttlMs: number

TTL applied to every stored response.

methods?: string[]

HTTP methods to cache. Defaults to GET and HEAD.

statuses?: number[]

Response statuses to cache. Defaults to [200].

vary?: string[]

Request header names included in the cache key.

header?: false | string

Diagnostic header name, or false to disable it. Defaults to x-fino-cache.

Functions

function memoryCache(opts: MemoryCacheOptions = {}): Cache

Create an in-memory LRU cache.

Recency is tracked per key: reads refresh an entry's position, and when a set() pushes the cache past maxEntries the least recently used entries are evicted regardless of namespace. Entries are lost when the process exits and are not shared across realms or processes. LRU size is counted in entries, not bytes.

import { memoryCache } from 'fino:cache';

const cache = memoryCache({ maxEntries: 2 });
await cache.set('one', 1);
await cache.set('two', 2);
await cache.get('one');      // refreshes 'one'
await cache.set('three', 3); // evicts 'two', the least recently used

await cache.get('two'); // null
await cache.get('one'); // 1

function sqliteCache(opts: SqliteCacheOptions): Promise<SqliteCache>

Open a SQLite-backed cache.

Opens (creating if necessary) the database at opts.path and ensures the fino_cache_entries and fino_cache_tags tables exist, so the same file can also hold unrelated application tables. Entries persist across process restarts; expired entries are removed lazily when a read observes them. Call close() on the returned handle when the cache is no longer needed.

import { sqliteCache } from 'fino:cache';

const cache = await sqliteCache({ path: '/var/lib/myapp/cache.db' });
let report = await cache.get<string>('report:2026-07');
if (report === null) {
  report = 'expensive result';
  await cache.set('report:2026-07', report, { ttlMs: 3_600_000, tags: ['reports'] });
}
await cache.close();

function responseCache(cache: Cache, opts: ResponseCacheOptions): LayerMiddleware

Create HTTP response-cache middleware for fino:net/http/app.

Requests whose method is in methods (default GET/HEAD) are looked up by method, full URL, and the values of the configured vary request headers. On a hit the stored status, headers, and body are replayed without invoking downstream handlers. On a miss the downstream response is stored when it is safe to reuse: its status is in statuses (default [200]), it carries no Set-Cookie header, and its Cache-Control does not include no-store.

Unless disabled with header: false, every response gains a diagnostic header (default x-fino-cache) valued HIT, MISS, or BYPASS, so cache behavior is observable in tests and from clients. Stored entries expire after ttlMs; pair the cache with invalidateTags() or delete() on a namespaced view if routes need explicit invalidation.

import { memoryCache, responseCache } from 'fino:cache';
import { App } from 'fino:net/http/app';

const app = new App()
  .layer(responseCache(memoryCache({ maxEntries: 500 }), {
    ttlMs: 5_000,
    vary: ['accept-language']
  }));

app.get('/hello').handle(() => new Response('hello'));
// First request: x-fino-cache: MISS. Repeats within 5s: HIT.