cachora
v0.1.0
Published
A type-safe, resilient and extensible cache toolkit for Node.js and NestJS
Maintainers
Readme
Cachora
A strict, type-safe cache toolkit for Node.js and NestJS with pluggable stores, local/distributed locking, stale-while-revalidate, tag invalidation, bulk operations and multi-tier caching.
See the complete usage guide for installation, configuration, feature examples, API behavior, NestJS integration, and production guidance.
Requirements
- Node.js 20 or newer
- TypeScript 5.5 or newer when consuming TypeScript declarations
Architecture
createCache() is the framework-neutral composition root. The public Cache
facade delegates to focused read, write, computation, entry-policy,
observability, and maintenance services. Store contracts are split by capability
(ReadableStore, WritableStore, DeletableStore, bulk contracts, lifecycle,
clear, and tag generations), while the original composite Store and
BulkStore contracts remain available.
Applications extend Cachora by supplying abstractions rather than changing
core code: Store, Serializer, LockProvider, Telemetry,
BackgroundTaskRunner, TierFailureReporter, and the NestJS CacheFactory
token are explicit extension points.
Install
npm install cachoraInstall only the optional integration you use:
npm install redis
npm install @aws-sdk/client-s3
npm install @nestjs/common @nestjs/core reflect-metadata rxjsNode.js
import {
createCache,
MemoryStore,
} from 'cachora';
const cache = createCache({
store: new MemoryStore({
maxEntries: 10_000,
maxSizeBytes: 64 * 1024 * 1024,
}),
keyspace: {
namespace: 'invoice-api',
version: 1,
},
defaults: {
ttlMs: 60_000,
staleTtlMs: 30_000,
jitterRatio: 0.1,
},
});
const invoice = await cache.getOrCompute(
`invoice:${invoiceId}`,
({ signal }) => invoiceRepository.find(invoiceId, { signal }),
{
tags: ['invoices', `invoice:${invoiceId}`],
lock: 'local',
},
);
await cache.invalidateTag(`invoice:${invoiceId}`);
await cache.close();Falsy values including undefined, null, false, 0 and empty strings are
valid cached values. Use getWithMetadata() when the application must
distinguish a cached undefined from a cache miss.
Expiration and refresh
const value = await cache.getOrCompute('expensive-report', buildReport, {
ttlMs: 60_000,
staleTtlMs: 30_000,
staleIfErrorMs: 5 * 60_000,
earlyRefresh: {
beta: 1,
minimumRemainingTtlMs: 5_000,
},
refresh: 'background',
});Early refresh uses stored factory computation time and deterministic injectable
clock/random sources. staleIfErrorMs extends physical retention, but an
expired value is returned only after refresh fails and only inside that window.
Background refresh requires an explicit BackgroundTaskRunner; without one,
refresh remains blocking so serverless runtimes do not lose untracked work.
Redis
The Redis adapter accepts a structural client interface, so the library core does not own or configure application infrastructure.
import { createClient } from 'redis';
import { createCache } from 'cachora';
import {
RedisLockProvider,
RedisStore,
} from 'cachora/redis';
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
const cache = createCache({
store: new RedisStore({
client,
keyPrefix: 'billing',
ownsClient: false,
}),
lock: new RedisLockProvider({ client }),
keyspace: { namespace: 'invoice-api', version: 2 },
defaults: {
lock: 'local+distributed',
ttlMs: 60_000,
},
});When ownsClient is false, cache.close() does not close the supplied Redis
client.
Multi-tier
import {
createCache,
MemoryStore,
TieredStore,
} from 'cachora';
import { RedisStore } from 'cachora/redis';
const store = new TieredStore({
tiers: [
{
store: new MemoryStore({ maxEntries: 5_000 }),
ttlCapMs: 30_000,
},
{
store: new RedisStore({ client: redis }),
ttlCapMs: 15 * 60_000,
},
],
promotion: 'best-effort',
write: 'all-settled',
});
const cache = createCache({
store,
keyspace: { namespace: 'invoice-api' },
});Reads start at L1. Values found in a lower tier are promoted upward without
extending their remaining lifetime. Best-effort failures are published to the
cachora:tiered-store:error Node diagnostics channel by default. Supply a
TierFailureReporter to integrate another observability strategy. When tests
or the application use a custom Clock, pass the same instance to
TieredStore and createCache.
File and S3
import { FileStore } from 'cachora/file';
import { S3Store } from 'cachora/s3';
const fileStore = new FileStore({
directory: '/var/tmp/my-application-cache',
});
const s3Store = new S3Store({
client: s3,
bucket: 'application-cache',
prefix: 'production',
});The file adapter hashes keys and writes atomically. The S3 adapter never creates buckets or modifies bucket lifecycle configuration.
NestJS
Synchronous configuration
import { Module } from '@nestjs/common';
import { CacheModule } from 'cachora/nestjs';
import { MemoryStore } from 'cachora';
@Module({
imports: [
CacheModule.forRoot({
isGlobal: true,
cacheOptions: {
store: new MemoryStore({ maxEntries: 10_000 }),
keyspace: { namespace: 'invoice-api' },
defaults: { ttlMs: 60_000 },
},
}),
],
})
export class AppModule {}Asynchronous configuration
CacheModule.forRootAsync({
isGlobal: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
cacheOptions: {
store: new RedisStore({
client: config.getOrThrow('REDIS_CLIENT'),
}),
keyspace: {
namespace: config.getOrThrow('SERVICE_NAME'),
},
},
}),
});Injection
import { Injectable } from '@nestjs/common';
import { InjectCache } from 'cachora/nestjs';
import type { Cache } from 'cachora';
@Injectable()
export class InvoiceService {
constructor(@InjectCache() private readonly cache: Cache) {}
find(invoiceId: string) {
return this.cache.getOrCompute(
`invoice:${invoiceId}`,
() => this.loadInvoice(invoiceId),
{ tags: [`invoice:${invoiceId}`] },
);
}
}The Nest lifecycle provider closes resources during application shutdown.
Custom cache factory
CacheModule depends on CACHE_FACTORY, so applications can replace cache
construction with an injectable Strategy. In asynchronous registration, put
the custom provider in extraProviders:
import { Injectable } from '@nestjs/common';
import {
createCache,
type Cache,
type CreateCacheOptions,
} from 'cachora';
import {
CACHE_FACTORY,
CacheModule,
type CacheFactory,
} from 'cachora/nestjs';
@Injectable()
class ApplicationCacheFactory implements CacheFactory {
constructor(private readonly telemetry: ApplicationTelemetry) {}
create(options: CreateCacheOptions): Cache {
return createCache({ ...options, telemetry: this.telemetry });
}
}
CacheModule.forRootAsync({
useFactory: () => ({ cacheOptions }),
extraProviders: [
ApplicationTelemetry,
{ provide: CACHE_FACTORY, useClass: ApplicationCacheFactory },
],
});Typed key schema
interface ApplicationCache {
'user-profile': {
params: { userId: string };
value: UserProfile;
};
'invoice-summary': {
params: { invoiceId: number };
value: InvoiceSummary | null;
};
}
const typedCache = createTypedCache<ApplicationCache>({
cache,
keys: {
'user-profile': ({ userId }) => `user:${userId}:profile`,
'invoice-summary': ({ invoiceId }) => `invoice:${invoiceId}:summary`,
},
});The key parameters, factory result and returned value are checked together at compile time.
Failure semantics
Default behavior favors application availability:
- read infrastructure failure becomes a miss and emits an error event
- write failure is reported but does not discard a valid factory result
- deletion/invalidation failure is thrown
- corrupt values are reported as misses
Override these choices through failure in createCache.
All Cachora-originated failures extend CacheError and expose stable code,
operation, store, retryable, and cause metadata. Caller cancellation is
never converted into an availability fallback: an aborted operation always
propagates its AbortSignal.reason.
Testing consumers
cachora/testing exports FakeClock, FixedRandom, and
CollectingTelemetry. NestJS integration can be isolated with
Test.createTestingModule() by mocking CACHE or overriding CACHE_FACTORY.
Public subpath exports
cachoracachora/memorycachora/filecachora/rediscachora/s3cachora/nestjscachora/testing
Compatibility
The Store contracts, Node.js entrypoint, and NestJS forRoot / forRootAsync
registrations remain available. Method names were intentionally modernized
before the stable release: use getWithMetadata, getOrCompute,
getAndDelete, exists, and wrapFunction. Invalid configuration now fails
earlier with typed errors, lifecycle failures are no longer silently ignored,
and caller cancellation always propagates. See CHANGELOG.md
for the complete migration summary.
