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

@oficinabrasil/declarative-cache

v1.0.1

Published

Declarative cache decorators for TypeScript with Memory and Redis providers, tags, TTL and stampede protection.

Readme

Declarative Cache

Cache declarativo e reutilizável para TypeScript/Node.js, pensado para repositories e aplicações com DDD.

O pacote permite cachear, atualizar e invalidar resultados usando decorators, sem acoplar seus repositories a Redis, memória ou qualquer implementação específica.

@Cacheable({
  key: (tenantId, id) => cacheKey('tenant', tenantId, 'user', id),
  ttl: 300,
  tags: (tenantId, id) => [
    cacheKey('tenant', tenantId, 'users'),
    cacheKey('tenant', tenantId, 'user', id),
  ],
})
async findById(tenantId: number, id: string) {
  return this.prisma.user.findUnique({ where: { id } });
}

Recursos

  • @Cacheable para cachear retornos
  • @CachePut para atualizar o cache após writes
  • @CacheInvalidate para invalidar chaves ou tags
  • TTL por entrada
  • invalidação por tags
  • namespaces e suporte natural a multi-tenant
  • MemoryCacheProvider
  • RedisCacheProvider
  • proteção contra cache stampede
  • deduplicação local de promises
  • distributed lock para Redis
  • modo fail-open através de ResilientCacheProvider
  • unless para impedir cache de resultados específicos
  • estatísticas básicas
  • provider totalmente desacoplado dos decorators

Requisitos

  • Node.js 20+
  • TypeScript 5+
  • experimentalDecorators: true

Para Redis, use um client compatível com a interface exposta por node-redis.

Instalação

npm install @oficinabrasil/declarative-cache

Se for utilizar Redis:

npm install redis

Configuração TypeScript

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "experimentalDecorators": true,
    "strict": true
  }
}

Arquitetura

Repository
    │
    ├── @Cacheable
    ├── @CachePut
    └── @CacheInvalidate
            │
            ▼
       CacheProvider
        /        \
       /          \
      ▼            ▼
 MemoryCache    RedisCache
                     │
                     ▼
                  Redis

Os decorators conhecem apenas CacheProvider. Nenhum decorator sabe como Redis ou memória funcionam.

CacheProvider

export interface CacheProvider {
  get<T>(key: string): Promise<T | null>;

  set<T>(
    key: string,
    value: T,
    options?: CacheSetOptions,
  ): Promise<void>;

  delete(key: string): Promise<void>;
  deleteMany(keys: string[]): Promise<void>;

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

  getOrSet<T>(
    key: string,
    factory: () => Promise<T>,
    options?: CacheGetOrSetOptions<T>,
  ): Promise<T>;
}

Isso permite criar outros providers sem alterar seus repositories.

Exemplo completo com repository

import {
  Cacheable,
  CacheInvalidate,
  CachePut,
  CacheableRepository,
  cacheKey,
} from '@oficinabrasil/declarative-cache';

interface User {
  id: string;
  tenantId: number;
  name: string;
}

export class UserRepository extends CacheableRepository {
  constructor(
    private readonly prisma: PrismaClient,
    cache: CacheProvider,
  ) {
    super(cache);
  }

  @Cacheable<[number, string], User | null>({
    key: (tenantId, id) =>
      cacheKey('tenant', tenantId, 'user', id),

    tags: (tenantId, id) => [
      cacheKey('tenant', tenantId, 'users'),
      cacheKey('tenant', tenantId, 'user', id),
    ],

    ttl: 300,
    unless: (result) => result === null,
  })
  async findById(
    tenantId: number,
    id: string,
  ): Promise<User | null> {
    return this.prisma.user.findFirst({
      where: {
        id,
        tenantId,
      },
    });
  }

  @Cacheable<[number], User[]>({
    key: (tenantId) =>
      cacheKey('tenant', tenantId, 'users', 'all'),

    tags: (tenantId) => [
      cacheKey('tenant', tenantId, 'users'),
    ],

    ttl: 120,
  })
  async findAll(tenantId: number): Promise<User[]> {
    return this.prisma.user.findMany({
      where: { tenantId },
    });
  }

  @CachePut<[number, string, string], User>({
    key: ({ args: [tenantId, id] }) =>
      cacheKey('tenant', tenantId, 'user', id),

    tags: ({ args: [tenantId, id] }) => [
      cacheKey('tenant', tenantId, 'users'),
      cacheKey('tenant', tenantId, 'user', id),
    ],

    ttl: 300,
  })
  async update(
    tenantId: number,
    id: string,
    name: string,
  ): Promise<User> {
    return this.prisma.user.update({
      where: { id },
      data: { name },
    });
  }

  @CacheInvalidate<[number, string], void>({
    tags: ({ args: [tenantId, id] }) => [
      cacheKey('tenant', tenantId, 'users'),
      cacheKey('tenant', tenantId, 'user', id),
    ],
  })
  async delete(
    tenantId: number,
    id: string,
  ): Promise<void> {
    await this.prisma.user.delete({
      where: { id },
    });
  }
}

MemoryCacheProvider

Ideal para:

  • desenvolvimento local
  • testes
  • aplicações de uma única instância
  • caches locais e extremamente rápidos
import { MemoryCacheProvider } from '@oficinabrasil/declarative-cache';

const cache = new MemoryCacheProvider({
  defaultTtl: 300,
  maxEntries: 10_000,
});

Cache stampede em memória

Chamadas simultâneas para a mesma chave compartilham a mesma Promise:

request A ──┐
request B ──┤
request C ──┤── MISS ── database
request D ──┘              │
                            ▼
                          cache

Apenas uma factory é executada por processo.

RedisCacheProvider

import { createClient } from 'redis';
import {
  RedisCacheProvider,
  ResilientCacheProvider,
} from '@oficinabrasil/declarative-cache';

const redis = createClient({
  url: process.env.REDIS_URL,
});

await redis.connect();

const redisCache = new RedisCacheProvider(redis, {
  prefix: 'crm',
  defaultTtl: 300,
  lockTtlMs: 10_000,
  lockRetryMs: 50,
  lockMaxWaitMs: 5_000,
});

const cache = new ResilientCacheProvider(redisCache, {
  onError(error, operation) {
    console.error({ operation, error }, 'Cache failure');
  },
});

Para aplicações em produção, recomenda-se envolver Redis com ResilientCacheProvider.

Assim, uma indisponibilidade do Redis não transforma uma operação válida do banco em erro de negócio.

Fail-open

Com o wrapper resiliente:

Redis online
    │
    └── cache funciona normalmente

Redis offline
    │
    ├── GET vira cache miss
    ├── SET é ignorado
    ├── invalidation registra erro
    └── repository continua funcionando

Esse comportamento é especialmente importante quando cache é uma otimização e não fonte de verdade.

Tags

Tags resolvem o problema de invalidação de consultas relacionadas.

Imagine três caches:

user:123
users:all
users:active

Todos podem estar relacionados à entidade User.

@Cacheable({
  key: () => 'users:active',
  tags: () => ['users'],
})
@Cacheable({
  key: (id) => `user:${id}`,
  tags: (id) => ['users', `user:${id}`],
})

Agora um write pode invalidar:

@CacheInvalidate({
  tags: ({ args: [id] }) => [
    'users',
    `user:${id}`,
  ],
})

Não é necessário fazer SCAN, wildcard ou conhecer todas as cache keys existentes.

Multi-tenant

Nunca compartilhe chaves entre tenants.

Ruim:

user:123

Preferível:

tenant:30:user:123

Use o helper:

import { tenantCacheKey } from '@oficinabrasil/declarative-cache';

const key = tenantCacheKey(30, 'user', '123');

Resultado:

tenant:30:user:123

@Cacheable

@Cacheable({
  key: (id) => `user:${id}`,
  ttl: 300,
})
async findById(id: string) {
  // ...
}

Fluxo:

method
  │
  ▼
cache.get
  │
  ├── HIT ───────────────► return
  │
  └── MISS
       │
       ▼
     factory
       │
       ▼
     cache.set
       │
       ▼
      return

Não cachear null

@Cacheable({
  key: (id) => `user:${id}`,
  ttl: 300,
  unless: (result) => result === null,
})

Também é possível cachear null se isso fizer sentido para seu domínio. Nesse caso, não use unless; porém, atualmente get() usa null como indicador de miss, então para negative caching prefira encapsular o resultado, por exemplo { found: false }.

@CacheInvalidate

A invalidação ocorre somente depois da operação original terminar com sucesso.

@CacheInvalidate({
  tags: ({ args: [tenantId, id] }) => [
    `tenant:${tenantId}:users`,
    `tenant:${tenantId}:user:${id}`,
  ],
})
async delete(tenantId: number, id: string) {
  // database delete
}

Fluxo:

DELETE/UPDATE no banco
        │
        ├── erro ─────► throw
        │
        ▼
      sucesso
        │
        ▼
 invalidar cache
        │
        ▼
      return

@CachePut

Use quando a operação já retorna a versão atualizada da entidade.

@CachePut({
  key: ({ args: [id] }) => `user:${id}`,
  ttl: 300,
})
async update(id: string, input: UpdateUserInput) {
  return database.update(id, input);
}

Isso evita:

UPDATE
  ↓
DELETE CACHE
  ↓
GET
  ↓
MISS
  ↓
DATABASE novamente

E substitui por:

UPDATE
  ↓
novo resultado
  ↓
SET CACHE

Transformando o valor

@CachePut({
  key: ({ result }) => `user:${result.id}`,
  value: ({ result }) => ({
    id: result.id,
    name: result.name,
  }),
})

Chaves versus tags

Use key invalidation quando você conhece exatamente uma entrada.

@CacheInvalidate({
  keys: ({ args: [id] }) => [`user:${id}`],
})

Use tags quando várias queries podem depender da mesma entidade ou agregado.

@CacheInvalidate({
  tags: ({ args: [tenantId] }) => [
    `tenant:${tenantId}:users`,
  ],
})

Estratégia sugerida de tags

Para uma entidade User em um sistema multi-tenant:

tenant:30:users

tenant:30:user:123

Uma query individual pode usar ambas:

tags: (tenantId, id) => [
  cacheKey('tenant', tenantId, 'users'),
  cacheKey('tenant', tenantId, 'user', id),
]

Uma listagem normalmente usa apenas a tag coletiva:

tags: (tenantId) => [
  cacheKey('tenant', tenantId, 'users'),
]

Proteção contra stampede

Memória

É feita por deduplicação de Promises dentro do processo.

Redis

O provider tenta adquirir um lock distribuído temporário:

cache:lock:<key>

Somente o processo que obtiver o lock executa a factory inicialmente.

Os demais aguardam brevemente pelo preenchimento da cache key.

O lock possui TTL para evitar deadlocks permanentes.

Após o tempo máximo de espera, disponibilidade tem prioridade sobre deduplicação perfeita e a chamada executa a factory.

Desabilitando lock

@Cacheable({
  key: (id) => `user:${id}`,
  lock: false,
})

Estrutura Redis

Para:

user:123

o provider cria uma chave semelhante a:

cache:data:user:123

Para a tag:

users

é utilizado um Redis Set:

cache:tag:users

que contém referências para as cache keys relacionadas.

Isso permite invalidação eficiente sem KEYS * ou SCAN de todo Redis.

Serialização

O provider Redis utiliza JSON.

Portanto, valores devem ser serializáveis.

Objetos como estes exigem tratamento específico antes de entrar no cache:

  • Date
  • BigInt
  • Map
  • Set
  • classes com protótipo relevante

Para entidades de domínio ricas, prefira cachear DTOs ou snapshots serializáveis.

Transações

Um ponto importante: decorators não sabem automaticamente se sua operação está dentro de uma transação de banco.

Este cenário é perigoso:

BEGIN
  UPDATE user
  invalidar cache
ROLLBACK

O banco voltou ao estado anterior, mas o cache já foi modificado.

Para operações transacionais complexas, prefira uma destas estratégias:

  1. aplicar decorators no método que representa a transação inteira;
  2. invalidar somente após commit;
  3. usar Unit of Work com callbacks afterCommit;
  4. publicar um evento de invalidação via Outbox após commit.

Em arquiteturas distribuídas, Transactional Outbox + invalidação assíncrona é a opção mais robusta quando consistência eventual é aceitável.

Cache não é fonte de verdade

O banco continua sendo a fonte de verdade.

Por isso a configuração recomendada em produção é:

Repository
   │
Decorators
   │
ResilientCacheProvider
   │
RedisCacheProvider
   │
Redis

Se Redis cair, a aplicação continua consultando o banco.

Observabilidade

Providers expõem estatísticas básicas quando suportado:

console.log(cache.stats?.());

Exemplo:

{
  hits: 1000,
  misses: 90,
  sets: 88,
  deletes: 12,
  errors: 0,
}

Em produção, recomenda-se exportar métricas como:

  • cache hit rate
  • misses
  • latency do Redis
  • quantidade de writes
  • invalidations
  • lock contention
  • errors
  • fallback para banco

Exemplo de bootstrap

Desenvolvimento

const cache = new MemoryCacheProvider({
  defaultTtl: 300,
});

Produção

const redis = createClient({
  url: process.env.REDIS_URL,
});

await redis.connect();

const cache = new ResilientCacheProvider(
  new RedisCacheProvider(redis, {
    prefix: 'my-api',
    defaultTtl: 300,
  }),
  {
    onError(error, operation) {
      logger.warn({ error, operation }, 'cache unavailable');
    },
  },
);

Seu container de DI pode registrar ambos sob CacheProvider.

Organização do projeto

src/
├── contracts/
│   ├── cache-host.ts
│   ├── cache-provider.ts
│   ├── cache.types.ts
│   └── decorator.types.ts
│
├── decorators/
│   ├── cacheable.decorator.ts
│   ├── cache-invalidate.decorator.ts
│   └── cache-put.decorator.ts
│
├── providers/
│   ├── memory/
│   │   └── memory-cache.provider.ts
│   │
│   ├── redis/
│   │   ├── redis-cache.provider.ts
│   │   └── redis.types.ts
│   │
│   └── resilient-cache.provider.ts
│
├── utils/
│   ├── cache-error.ts
│   ├── cache-key.ts
│   └── require-cache.ts
│
└── index.ts

Testes

npm test

Build

npm run build

Saída:

dist/

Recomendações para produção

  • use Redis para aplicações com mais de uma instância;
  • use Memory apenas quando cache local for aceitável;
  • mantenha tenant ID em todas as chaves multi-tenant;
  • evite wildcard para invalidação;
  • prefira tags;
  • defina TTL mesmo quando houver invalidação explícita;
  • use ResilientCacheProvider para Redis;
  • cacheie DTOs serializáveis, não objetos complexos;
  • acompanhe hit ratio;
  • faça invalidação somente após commit;
  • use Outbox quando múltiplos serviços precisarem invalidar caches;
  • não coloque cache em métodos que precisam de consistência forte sem analisar as consequências.

Próximas evoluções possíveis

  • stale-while-revalidate (SWR)
  • jitter automático no TTL para evitar expiração simultânea
  • L1 Memory + L2 Redis
  • invalidation events via Redis Pub/Sub
  • OpenTelemetry
  • métricas Prometheus
  • adapters NestJS
  • integração com Unit of Work / afterCommit
  • serializers customizados
  • versionamento de namespaces
  • suporte a negative caching explícito

Licença

MIT