Cache

LRU + TTL cache with operator-supplied namespacing, drop-silent key validation on hot-path observability, and pluggable backends that share semantics across single-process and clustered nodes.

Three first-class backends ship in the box:

- "memory" (default) — Map + LRU eviction (maxEntries) + bytes eviction (maxBytes) + periodic sweep. Single-process accuracy. - "cluster" — _blamejs_cache table via cluster-storage. One table serves every CacheInstance via ":" composite key; ON CONFLICT UPSERT for atomic set. - "redis" — cache-redis client; sliding TTL via EXPIRE; tag wipes via SCAN+DEL on a per-namespace prefix.

A { get, set, del, clear, size, close } operator-supplied object is the custom-backend escape hatch (Memcached, in-memory harnesses, anything else with the same async surface).

Hot-path validation policy:

- create() opts → throw at boot (config-time) - key arg on get/set/del → throw at call site (programming bug) - per-call ttlMs override → throw at call site (silent footgun if accepted) - audit / observability → drop silent (hot-path sink) - method-after-close → throw BAD_STATE

Security defaults that are NOT opt-in:

- auditClear: true mass purges are operator-action shaped - auditFailures: true backend errors are signal - hot-path get/set/hit/miss/eviction → observability only (the audit chain would drown at any reasonable QPS)

Returned CacheInstance shape:

get(key) → value | undefined set(key, value, opts?) → void (opts: { ttlMs, tags, seal }) del(key) → boolean has(key) → boolean (does NOT bump LRU recency) clear(opts?) → number (opts: { req, context }) size() → number bytes() → number (memory backend only) wrap(key, fn, opts?) → fn's return (opts: { ttlMs, singleFlight }) invalidateTag(tag, opts?) → number (opts: { req, context }) getTags(key) → string[] | null close() → void

Stale-while-revalidate, single-flight wrap (concurrent calls collapse to one compute), tag-based bulk invalidation (memory + cluster), and cross-node invalidation via b.pubsub are all built in — operator opts in via the staleWhileRevalidate / invalidationPubsub opts.

What is NOT in the box: maxBytes on the cluster backend (would require an aggregate query per set; operator prunes the shared table on their own schedule) and per-entry exact slidingTtl on the cluster backend (sliding extends by the cache's defaultTtlMs; operators with mixed-TTL writes wanting strict per-entry sliding use the memory backend or extend at the application layer).

b.cache.create(opts) #

stable0.1.0
{
  namespace:               string,                       // required; collision domain; must not contain ':'
  backend:                 "memory" | "cluster" | "redis" | object,  // default "memory"
  ttlMs:                   number | Infinity,            // default C.TIME.minutes(5)
  maxEntries:              number | Infinity,            // memory backend cap; default 10000
  maxBytes:                number | Infinity,            // memory backend cap; default Infinity
  sizeOf:                  function(value) -> number,    // memory bytes accounting override
  sweepIntervalMs:         number,                       // expired-entry sweep cadence; default C.TIME.minutes(1); minimum 1000
  staleWhileRevalidate:    boolean,                      // wrap() serves stale + refreshes in background; default false
  slidingTtl:              boolean,                      // bump expiresAt on hit; default false
  auditFailures:           boolean,                      // emit audit on backend errors; default true
  auditClear:              boolean,                      // emit audit on clear / invalidateTag; default true
  audit:                   { emit } | b.audit,           // audit sink override
  observability:           { event } | b.observability,  // metrics sink override
  clock:                   function() -> number,         // Date.now() override (testing)
  invalidationPubsub:      b.pubsub instance,            // cross-node del/clear/tag mirroring
  redisUrl:                string,                       // backend === "redis" only; required there
  redisPassword:           string,                       // backend === "redis" only
  redisUsername:           string,                       // backend === "redis" only
  redisTls:                boolean,                      // backend === "redis" only
  redisCa:                 string | Buffer,              // backend === "redis" only; PEM CA bundle
  redisServername:         string,                       // backend === "redis" only; SNI override
  redisConnectTimeoutMs:   number,                       // backend === "redis" only
  redisCommandTimeoutMs:   number,                       // backend === "redis" only
  redisMaxReconnectAttempts: number,                     // backend === "redis" only
}

Build a CacheInstance bound to a namespace. The instance owns its sweep timer, its backend connection, its single-flight inflight map, and (when invalidationPubsub is supplied) a pubsub subscription that mirrors del / clear / invalidateTag events across nodes. Multiple instances coexist — a "session.user" memory cache and a "billing.invoice" cluster cache share neither keys nor tags. close() releases everything.

The backend opt picks the storage tier: "memory" (default, single-process LRU+TTL), "cluster" (shared SQL table for multi-node coherence), "redis" (when redisUrl is supplied; native EXPIRE-based TTL), or an operator-supplied object with { get, set, del, clear, size, close } for any other store. Backends are interchangeable from the caller's perspective — await cache.get(key) returns the same shape regardless.

var b = require("@blamejs/core");
var C = b.constants;

// Simple set/get against the default memory backend.
var sessions = b.cache.create({
  namespace:  "session.user",
  ttlMs:      C.TIME.minutes(5),
  maxEntries: 10000,
});
await sessions.set("u-42", { uid: "u-42", role: "admin" });
var hit = await sessions.get("u-42");
// → { uid: "u-42", role: "admin" }

var b = require("@blamejs/core");
var C = b.constants;

// wrap() pattern with per-call TTL override + single-flight.
// Concurrent callers collapse to one DB read; subsequent reads
// serve from cache for 10 minutes.
var profiles = b.cache.create({
  namespace: "billing.profile",
  ttlMs:     C.TIME.minutes(2),
});
var profile = await profiles.wrap(
  "u-42",
  function () { return { uid: "u-42", plan: "pro" }; },
  { ttlMs: C.TIME.minutes(10) }
);
// → { uid: "u-42", plan: "pro" }

var b = require("@blamejs/core");
var C = b.constants;

// Cluster-shared cache: every node sees the same entries via the
// _blamejs_cache table. Tag-based bulk invalidation purges across
// every namespace member in one call.
var inventory = b.cache.create({
  namespace: "catalog.item",
  backend:   "cluster",
  ttlMs:     C.TIME.minutes(15),
});
await inventory.set("sku-1001", { qty: 42 }, { tags: ["warehouse:east"] });
var purged = await inventory.invalidateTag("warehouse:east");
// → 1

b.cache.update(key, mutatorFn, opts?) #

stable0.13.39
{
  ttlMs:  number | Infinity,   // lifetime of the written value; default the instance ttlMs
  seal:   boolean,             // cluster backend only — seal the value at rest
}

Atomic read-modify-write. Reads the current value, calls mutatorFn(current | null), and commits the result in one operation so a concurrent writer cannot clobber the change (lost update) — the race that makes a plain get → mutate → set unsafe for counters, sets, and quorum state. The memory backend is atomic by single-thread; the cluster backend uses a transaction with compare-and-set + retry.

mutatorFn returns one of: { value } to commit the new value, { abort: data } to leave the entry untouched and surface data to the caller, or { delete: true } to remove the entry. A committing decision may also set the written value's lifetime — { value, ttlMs } (a duration the backend resolves against its own clock) or { value, expiresAt } (an absolute time) — when the new value's own state decides how long it should live; otherwise the call ttlMs applies. The call resolves to { updated: true, value }, { updated: true, deleted: true }, or { aborted: data }.

await counters.update("hits", function (n) {
  return { value: (n || 0) + 1 };
});

Last updated 2026-08-08T16:39:49.652Z by seeder.