Service API
Inject with the COUNTER_SERVICE token:
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
| Method | Description |
|---|---|
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):
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
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).