Skip to content

Service API ​

Inject with the COUNTER_SERVICE token:

typescript
import { Injectable, Inject } from '@nestjs/common';
import { COUNTER_SERVICE, ICounterService } from '@nestjs-redisx/counters';

@Injectable()
export class StatsService {
  constructor(
    @Inject(COUNTER_SERVICE)
    private readonly counters: ICounterService,
  ) {}

  // Atomic fleet-wide increment - concurrent updates never get lost.
  async onArticleView(articleId: string): Promise<number> {
    return this.counters.increment(`views:${articleId}`);
  }

  async getViews(articleId: string): Promise<number> {
    return this.counters.get(`views:${articleId}`); // 0 when absent
  }

  // Counters go negative freely - clamp in domain code if needed.
  async releaseSeat(eventId: string): Promise<number> {
    return this.counters.decrement(`seats:${eventId}`);
  }

  async resetViews(articleId: string): Promise<boolean> {
    return this.counters.reset(`views:${articleId}`);
  }
}

ICounterService ​

MethodDescription
increment(key, by = 1, { ttl }?)Atomic add; returns the new value. With ttl: first-write window semantics
decrement(key, by = 1, { ttl }?)Atomic subtract; counters go negative freely
get(key)Current value; 0 when absent
set(key, value, { ttl }?)Absolute assignment; without ttl an existing window TTL is preserved (a mid-window correction never makes the key permanent)
reset(key)Delete; true when a counter existed
getAndReset(key)Atomic read-and-zero (Lua); 0 when absent
next(sequence)Monotonic sequence value (INCR, never a TTL)
current(sequence)Last issued value; 0 before the first next()

Window Semantics (ttl) ​

The TTL is applied only when the mutation creates the key (atomic Lua: EXISTS check before INCRBY, PEXPIRE only on creation - a pre-existing permanent counter is never retrofitted with a window):

typescript
import { Injectable, Inject } from '@nestjs/common';
import { COUNTER_SERVICE, ICounterService } from '@nestjs-redisx/counters';

@Injectable()
export class UsageService {
  constructor(
    @Inject(COUNTER_SERVICE)
    private readonly counters: ICounterService,
  ) {}

  // Window counter: the TTL applies only when the increment CREATES the
  // key - later increments never extend the window, so daily keys expire
  // on their own without a cleanup job.
  async onApiCall(userId: string): Promise<number> {
    const today = new Date().toISOString().slice(0, 10);
    return this.counters.increment(`api:${userId}:${today}`, 1, { ttl: 86_400_000 });
  }

  // Flush-style consumption: read the accumulated total and zero it in ONE
  // atomic step - periodic aggregation without double counting.
  async flushEmailBatch(): Promise<number> {
    return this.counters.getAndReset('emails:batch');
  }
}

The window is anchored to the FIRST write - later increments never push it forward, so a daily key expires a day after its first event, not its last. For calendar-aligned windows, put the period in the key (:2026-10-09).

Sequences ​

typescript
import { Injectable, Inject } from '@nestjs/common';
import { COUNTER_SERVICE, ICounterService } from '@nestjs-redisx/counters';

@Injectable()
export class InvoiceNumberService {
  constructor(
    @Inject(COUNTER_SERVICE)
    private readonly counters: ICounterService,
  ) {}

  // Monotonic fleet-wide sequence: 1, 2, 3, ... with no collisions across
  // instances. Sequences have no TTL by definition.
  async nextInvoiceNumber(): Promise<string> {
    const n = await this.counters.next('seq:invoices');
    return `INV-${String(n).padStart(6, '0')}`;
  }

  async lastIssued(): Promise<number> {
    return this.counters.current('seq:invoices'); // 0 before the first next()
  }
}

Values are strictly monotonic and collision-free across instances. They are not gapless - a crashed consumer may abandon a value; ledgers that demand gapless numbering need an application-level protocol, not a counter.

Poisoned Keys ​

get, current, and getAndReset throw CounterValueError (code OP_TYPE_MISMATCH) when the key holds a non-numeric value - a shared-key wiring bug fails loudly instead of leaking NaN into aggregates. Mutations on such keys fail inside Redis itself (CommandError).

Released under the MIT License.