npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nage-api/cache

v1.0.0-beta.4

Published

Typed two-tier cache for @nage-api — memory L1, pluggable remote L2, namespaced invalidation

Readme

@nage-api/cache

A typed cache that fails soft (PLAN.md §8, §25 P1).

Three drivers behind one service: memory for a single instance, redis for a shared one, two-tier for both. Application code injects CacheService and never learns which it got.

Wiring it up

import { Module } from '@nestjs/common';
import { NageCacheModule, type RedisClientPort } from '@nage-api/cache';
import type { LoggerPort } from '@nage-api/contracts';

// Whichever client the application already has: ioredis, node-redis, or a fake
// in tests. This package names the six commands it needs and depends on none of
// them. Only `redis` and `two-tier` require one.
declare const redis: RedisClientPort;
declare const logger: LoggerPort;

@Module({
  imports: [
    NageCacheModule.forRoot({
      cache: { driver: 'two-tier', namespace: 'shop', ttl: '5m' },
      redis,
      // A broken shared tier is reported, not thrown. Without a handler the
      // failure is silent and the only symptom is a slower application.
      onRemoteError: (operation, error) => {
        logger.warn('cache: remote tier failed', { operation, error });
      },
    }),
  ],
})
export class AppModule {}

Using it

CacheService is exported by the module, so injecting the class is enough.

import { Injectable } from '@nestjs/common';
import { CacheService } from '@nage-api/cache';

interface Product {
  readonly id: string;
  readonly name: string;
}

declare const repository: { findById(id: string): Promise<Product | undefined> };

@Injectable()
export class ProductService {
  constructor(private readonly cache: CacheService) {}

  async find(id: string): Promise<Product | undefined> {
    return this.cache.wrap(`product:${id}`, () => repository.findById(id), {
      ttl: '5m',
      tags: [`product:${id}`],
    });
  }

  async onProductChanged(id: string): Promise<void> {
    // Drops the row's own entry and every list that included it.
    await this.cache.invalidateTag(`product:${id}`);
  }

  async onBulkImport(): Promise<void> {
    await this.cache.namespace('product').invalidateNamespace();
  }
}

What it adds over a Map

Single-flight. Concurrent wrap calls for one key run the loader once. A popular key expiring under load otherwise starts one identical query per in-flight request — the stampede that turns a cache miss into an outage. The in-flight entry is cleared in a finally, so a failed load doesn't poison the key for everyone who asks next.

Bounded memory. The in-process store evicts least-recently-used entries and sweeps expired ones on a timer. An unbounded map keyed by anything a request can influence is a memory leak with a slow fuse. The timer is unref'd, so a cache never keeps a process alive.

Namespaces and tags. Every key is prefixed, so a namespace can be dropped without knowing which keys it wrote, and an entry can carry labels so a write to one row invalidates every list that included it.

Absence is not cached by default. Caching undefined is how a race writes a tombstone over a value that arrived a millisecond later. cacheEmpty: true turns it on where "not found" is the common answer.

Two tiers

L1 is in-process, L2 is shared, and L1 holds a shorter TTL than L2 — capped by l1TtlMs, five seconds by default. That cap is the safety argument: an in-process copy is the one nothing can invalidate remotely, so staleness is bounded by a number you chose rather than by when a process happens to restart. A local delete clears L1 immediately, so the instance that made a change never reads its own stale value.

A cache is an optimisation, so a broken L2 degrades rather than throws: remote failures are reported through onRemoteError and the request is served from L1 or from the source. A value Redis returns that will not parse is a miss, not an exception.

Redis without a Redis dependency

RedisCacheStore is written against RedisClientPort — six commands — rather than against ioredis or node-redis. The heavyweight client stays out of the install for the deployments that never use it, and the store is testable without a daemon, which is what the suite does.

Two details worth keeping: prefix invalidation uses SCAN, never KEYS (which is O(n) over the keyspace and blocks the server every other service depends on), and clear() is scoped to the store's own prefix rather than flushing the database out from under its neighbours.

Not yet implemented

  • A @Cacheable() method decorator. The explicit wrap call is currently the only entry point, which keeps the cache key visible at the call site.
  • Stale-while-revalidate: CacheOutcome reserves a stale case for it.
  • Redis Cluster tag indexes; the tag set assumes a single keyspace.