@dcsv-io/d2-caching-local-default
v0.1.2
Published
<!-- Copyright (c) DCSV. Licensed under the Apache License, Version 2.0. -->
Readme
@dcsv-io/d2-caching-local-default
.NET mirror: DcsvIo.D2.Caching.Local.Default
Node/BFF authors who need a per-process ILocalCache inject this default in-process implementation — an LRU map store with TTL tracking, process-scope atomic primitives (set-if-absent, counters, locks), aggregate OTel counters, and @dcsv-io/d2-result shapes on every operation. This README covers usage, construction, per-operation semantics, TTL and eviction rules, telemetry, and disposal.
Install
pnpm add @dcsv-io/d2-caching-local-defaultUsage
import { DefaultLocalCache } from "@dcsv-io/d2-caching-local-default";
using cache = new DefaultLocalCache({
maxEntries: 10_000,
keyPrefix: "bff:",
});
const setR = await cache.set("session:1", { userId: "u1" });
if (!setR.success) {
throw new Error("set failed");
}
const getR = await cache.get<{ userId: string }>("session:1");
if (getR.success) {
console.log(getR.data.userId);
}
const counter = await cache.increment("hits");
if (counter.success) {
console.log(counter.data);
}Construction + options
new DefaultLocalCache(
options?: Partial<LocalCacheOptions>,
clock?: () => number,
);Partial<LocalCacheOptions> is merged over LOCAL_CACHE_DEFAULTS via
createLocalCacheOptions (both owned by @dcsv-io/d2-caching-abstractions). The
optional clock parameter defaults to Date.now and is the sole time source
for value TTL, lock expiry, and remaining-TTL arithmetic — inject a fake in
tests.
Construction throws RangeError on structurally broken numbers (negative /
non-integer / non-finite maxEntries; non-finite defaultExpirationMs).
Constructors throw; live operations return results. A throwing constructor is a
composition-time bug, never per-call data flow.
Construct once at the composition root and share the instance (singleton-equivalent usage).
Public surface
DefaultLocalCache— implementsILocalCache(ICacheBasic+ICacheAtomic) andDisposable.LOCAL_CACHE_METER_NAME,LOCAL_CACHE_METER_VERSION,LOCAL_CACHE_INSTRUMENTS— OpenTelemetry meter identity + instrument metadata SoT (used by counters and dual-runtime parity).
Basic: get, getMany, exists, getTtl, set, setMany, remove,
removeMany (each accepts optional trailing signal?: AbortSignal; durations
use *Ms number parameters).
Atomic: setNx, increment, acquireLock, releaseLock.
Result mapping
| Op | Success | Failure / special |
| -- | ------- | ----------------- |
| get | ok(value) | miss/expired → notFound; falsey key → VF(key) |
| getMany | all hit → ok(map); partial → someFound (206) | none → notFound; falsey keys → VF(keys) |
| exists | ok(true\|false) — never notFound | falsey key → VF(key) |
| getTtl | live+TTL → ok(ms); live no TTL → ok(undefined) | absent/expired → notFound |
| set / setMany | ok() | falsey / bad expirationMs → VF |
| remove / removeMany | ok() (idempotent) | falsey → VF |
| setNx | wrote → ok(true); exists → ok(false) with data === false | VF on bad input |
| increment | ok(newValue) | non-counter existing → conflict (409); VF on bad input / overflow |
| acquireLock | acquired → ok(true); held → ok(false) | VF on falsey key/lockId or non-positive finite expirationMs |
| releaseLock | always ok() (idempotent) | VF on falsey key/lockId |
Validation failures name the TS parameter (expirationMs, amount, key,
keys, entries, lockId) per the InputFailures call-site-name contract.
Validation (JS number guards)
Construction (RangeError, not D2Result):
maxEntries— non-negative safe integer (Number.isSafeIntegerand>= 0); rejectsNaN, ±Infinity, non-integers, and values outside safe-integer range.defaultExpirationMs— finite number (Number.isFinite);Number.MAX_VALUEis accepted;<= 0means “no default TTL” (not a construction error).
Per-call (validationFailed via InputFailures):
- Falsey
key/ element ofkeys/ map key inentries/lockId. - Optional
expirationMswhen present: must be finite and> 0(NaN/ ±Infinity/<= 0→ fieldexpirationMs). incrementamountwhen present: must be a safe integer (NaN/ ±Infinity/ non-integer / non-safe → fieldamount).incrementafter computingnext = current + amount: if!Number.isSafeInteger(next)→ fieldamountbefore write (store unchanged). Existing non-safe-integer / non-number values →conflict.acquireLockexpirationMs: finite and> 0(required).
TTL semantics
- Omitted
expirationMson write →defaultExpirationMs. defaultExpirationMs <= 0means entries store without expiry.- Explicit
expirationMsmust be a positive finite number (zero / negative / non-finite are validation failures, never "no TTL"). increment/setNxapply TTL only on create; existing keys preserve TTL (incrementignoresexpirationMson the existing path).getTtlhas three outcomes:notFound/ok(ms)/ok(undefined).- Expiry is checked lazily on access with the injected clock (strict
<=).
Capacity + eviction
maxEntries is a literal entry-count cap. Every access moves the touched entry
to the most-recently-used position; when a write pushes the count past
maxEntries, the least-recently-used entries are evicted synchronously in the
same call, so the count never exceeds the cap. The .NET twin delegates to
IMemoryCache (SizeLimit + 5% async compaction), where eviction lags the
overflowing write; this implementation evicts deterministically instead. The
evictions counter counts capacity evictions and expired-entries-detected-on-access
only - explicit removes and overwrites are not evictions.
Values and expirations live in one record, so the .NET-documented transient
no-TTL getTtl report after a capacity eviction cannot occur here.
Atomicity model
Atomic primitives are process-scope only (cluster coordination is
IDistributedCache territory). Every operation body is synchronous (no
internal awaits), which is what makes check-then-write windows atomic on the
event loop. Locks live in a store separate from values; re-acquiring a held
lock returns ok(false) even for the same lockId; release with a wrong id is
a no-op.
The lock store is not capped by maxEntries and has no sweeper: an expired lock
entry lingers until it is reacquired, released by its holder, or the cache is
disposed (the .NET twin behaves the same way).
Telemetry
Meter "DcsvIo.D2.Caching.Local" v1.0.0 with five aggregate counters (no tags,
no spans, no logs — per-call instrumentation would dominate sub-microsecond
work):
| Name | Unit | Description | When |
| ---- | ---- | ----------- | ---- |
| d2.cache.local.hits | {hit} | Local cache hits. | get / getMany hits |
| d2.cache.local.misses | {miss} | Local cache misses. | get / getMany misses |
| d2.cache.local.sets | {write} | Local cache writes. | set / setMany / successful setNx / new-key increment |
| d2.cache.local.removes | {removal} | Local cache removals (explicit). | every remove / removeMany key (incl. absent) |
| d2.cache.local.evictions | {eviction} | Entries evicted by capacity / expiration. | capacity + expired-on-access only |
increment on an existing counter does not increment sets.
Counters bind to the global OpenTelemetry MeterProvider at construction time. Construct caches after telemetry setup (setupTelemetry from @dcsv-io/d2-telemetry) or the counters bind to the no-op meter for the life of the instance. With no MeterProvider registered every operation still works - metric adds are silent no-ops.
Disposal
dispose() clears all state and is idempotent; the class also implements
Symbol.dispose for using declarations. Every operation on a disposed
instance throws an Error ("DefaultLocalCache is disposed.") - a disposed cache
is a lifecycle bug, not a per-call failure, so it does not surface as a
D2Result. The disposal check runs before input validation.
Cancellation
signal parameters are accepted for interface parity and ignored: every
operation completes synchronously in process, so there is no cancellation window
(the .NET twin ignores CancellationToken the same way).
Divergences from the .NET implementation
- Synchronous LRU vs async IMemoryCache compaction (see Capacity + eviction).
- Single-record TTL (no transient no-TTL
getTtlafter capacity eviction). - Post-dispose exception type — every operation, including
acquireLock/releaseLock, throws afterdispose()(matching .NET fail-closed dispose on all public ops including locks). TS throws a plainErrorwith a pinned message; .NET throwsObjectDisposedException. - Validation additionally rejects non-finite
expirationMs/ non-safe-integeramount, and refuses anincrementwhose computed next value is outsideNumber.MAX_SAFE_INTEGER(fieldamount; write skipped). Thenumbertype admits valuesTimeSpan/longstructurally cannot. - Counter values are safe-integer JS numbers (port-documented bound); precision beyond that range is not supported.
get<T>type parameters are caller-asserted and erased at runtime (the .NET generic cast throws on a wrong-type read; TS cannot).
Dependencies
@dcsv-io/d2-caching-abstractions— ports + options +InputFailures@dcsv-io/d2-result— result factories@dcsv-io/d2-utilities—falsey@opentelemetry/api— counters
Sister packages
@dcsv-io/d2-caching-abstractions— ports + result mapping + key convention / PII guidance@dcsv-io/d2-caching-distributed-redis— Redis + backplane@dcsv-io/d2-caching-tiered— L1+L2 composition- .NET twin:
DcsvIo.D2.Caching.Local.Default
