Aviary

Stores

Share circuit-breaker state across instances with a pluggable ResilienceStore — in-memory by default, or Redis / Postgres / SQLite adapters that coordinate the half-open probe atomically.

The circuit breaker keeps its state — failure counts, open-until timestamps, the half-open probe — behind a small ResilienceStore interface. Swap the implementation to move that state from one process to your whole fleet.

interface ResilienceStore {
  admit(key: string, cfg: BreakerConfig): Promise<Admission>;            // may I run? (and am I the probe?)
  record(key: string, cfg: BreakerConfig, ok: boolean, probe: boolean): Promise<CircuitStatus>;
  snapshot(key: string): Promise<CircuitSnapshot>;                       // read-only inspect
}

admit and record must be atomic — the whole point of a distributed store is that, under concurrent load, exactly one instance gets the half-open probe. Every adapter below enforces that against its real engine (Lua, SELECT … FOR UPDATE, a SQLite transaction) and is verified by a shared contract suite.

In-memory (default)

InMemoryResilienceStore ships in the core package and needs nothing. State lives in the process, so each instance trips its own breaker:

import { InMemoryResilienceStore } from '@dudousxd/nestjs-resilience';

const store = new InMemoryResilienceStore(); // accepts an optional Clock for tests

Distributed adapters

Each adapter is its own package with the engine driver as a peer dependency. Pick one:

PackageEngineConstructSchema
@dudousxd/nestjs-resilience-store-redisRedis (ioredis)new RedisResilienceStore(redis, opts?)none (atomic Lua)
@dudousxd/nestjs-resilience-store-typeormPostgres (TypeORM)new TypeOrmResilienceStore(dataSource, opts?)ensureSchema()
@dudousxd/nestjs-resilience-store-prismaPostgres (Prisma)new PrismaResilienceStore(prisma, opts?)ensureSchema()
@dudousxd/nestjs-resilience-store-mikro-ormPostgres (MikroORM)new MikroOrmResilienceStore(orm, opts?)ensureSchema()
@dudousxd/nestjs-resilience-store-drizzleSQLite (better-sqlite3)new DrizzleResilienceStore(db, opts?)exported schema
pnpm add @dudousxd/nestjs-resilience-store-redis ioredis
app.module.ts
import Redis from 'ioredis';
import { ResilienceModule } from '@dudousxd/nestjs-resilience';
import { RedisResilienceStore } from '@dudousxd/nestjs-resilience-store-redis';

ResilienceModule.forRootAsync({
  inject: [Redis],
  useFactory: (redis: Redis) => ({
    store: new RedisResilienceStore(redis, {
      keyPrefix: 'resilience:cb:', // default
    }),
  }),
});

admit/record each run as a single atomic Lua script — no schema, no migration.

pnpm add @dudousxd/nestjs-resilience-store-typeorm typeorm
app.module.ts
import { DataSource } from 'typeorm';
import { ResilienceModule } from '@dudousxd/nestjs-resilience';
import { TypeOrmResilienceStore } from '@dudousxd/nestjs-resilience-store-typeorm';

ResilienceModule.forRootAsync({
  inject: [DataSource],
  useFactory: async (dataSource: DataSource) => {
    const store = new TypeOrmResilienceStore(dataSource);
    await store.ensureSchema(); // create the circuits table once on boot
    return { store };
  },
});

Atomicity uses a transaction with SELECT … FOR UPDATE. Prefer migrations? Import the CIRCUITS_DDL string instead of calling ensureSchema().

pnpm add @dudousxd/nestjs-resilience-store-prisma @prisma/client
app.module.ts
import { PrismaService } from './prisma.service';
import { ResilienceModule } from '@dudousxd/nestjs-resilience';
import { PrismaResilienceStore } from '@dudousxd/nestjs-resilience-store-prisma';

ResilienceModule.forRootAsync({
  inject: [PrismaService],
  useFactory: async (prisma: PrismaService) => {
    const store = new PrismaResilienceStore(prisma);
    await store.ensureSchema();
    return { store };
  },
});

The store duck-types the client ($transaction / $executeRawUnsafe / $queryRawUnsafe), so it works across Prisma 5–7 without importing your generated client.

pnpm add @dudousxd/nestjs-resilience-store-mikro-orm @mikro-orm/core @mikro-orm/postgresql
app.module.ts
import { MikroORM } from '@mikro-orm/core';
import { ResilienceModule } from '@dudousxd/nestjs-resilience';
import { MikroOrmResilienceStore } from '@dudousxd/nestjs-resilience-store-mikro-orm';

ResilienceModule.forRootAsync({
  inject: [MikroORM],
  useFactory: async (orm: MikroORM) => {
    const store = new MikroOrmResilienceStore(orm);
    await store.ensureSchema();
    return { store };
  },
});

Uses em.transactional() — make sure transactions aren't disabled on your config.

pnpm add @dudousxd/nestjs-resilience-store-drizzle drizzle-orm better-sqlite3
app.module.ts
import { ResilienceModule } from '@dudousxd/nestjs-resilience';
import { DrizzleResilienceStore, resilienceSchema } from '@dudousxd/nestjs-resilience-store-drizzle';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';

ResilienceModule.forRootAsync({
  useFactory: () => {
    const db = drizzle(new Database('app.db'), { schema: resilienceSchema });
    return { store: new DrizzleResilienceStore(db) };
  },
});

SQLite-backed with synchronous transactions. The package exports circuits, resilienceSchema, and CIRCUITS_DDL for your migrations.

Wiring a store

Pass the store to the module — every breaker and decorator then shares it:

ResilienceModule.forRootAsync({
  inject: [Redis],
  useFactory: (redis: Redis) => ({ store: new RedisResilienceStore(redis) }),
});

…or to an individual circuitBreaker({ store, … }) / failover({ policy: () => circuitBreaker({ store, … }) }).

All adapters share one type, BreakerConfig (threshold, cooldownMs, halfOpenMax?) — the same options you pass to circuitBreaker. Bring your own engine by implementing the three methods and validating it against the contract suite.

Building a custom store

Your engine isn't in the table above? A store is just the three admit / record / snapshot methods, and the core package gives you everything to implement them without re-deriving the breaker's state machine. There are two paths depending on whether your engine speaks SQL.

Path A — reuse the SQL base (any relational engine)

Every relational adapter (store-typeorm, store-prisma, store-mikro-orm) is a few lines on top of the exported SqlResilienceStore. It owns the atomic load → compute → persist cycle, the dialect-aware statements, and the resilience_circuits DDL — so all you supply is a tiny SqlDriver: your placeholder dialect plus how to run a transaction, a plain read, and a DDL statement.

export type SqlPlaceholderStyle = 'numbered' | 'positional'; // `$1,$2…` (Postgres) vs `?`

/** A parameterized statement runner scoped to one transaction. */
interface SqlTx {
  run(sql: string, params: unknown[]): Promise<void>;   // INSERT/UPDATE, result discarded
  all(sql: string, params: unknown[]): Promise<unknown[]>; // SELECT, returns rows
}

/** The whole per-engine contract. Everything else lives in SqlResilienceStore. */
interface SqlDriver {
  readonly placeholders: SqlPlaceholderStyle;
  transaction<R>(body: (tx: SqlTx) => Promise<R>): Promise<R>; // MUST allow `SELECT … FOR UPDATE`
  read(sql: string, params: unknown[]): Promise<unknown[]>;    // non-transactional, for snapshot()
  exec(sql: string): Promise<void>;                            // run a DDL statement
}

interface SqlResilienceStoreOptions {
  clock?: Clock; // inject a FakeClock in tests; defaults to systemClock
}

Here's the entire TypeORM adapter — your own engine follows the same shape, only the four driver methods change:

my-engine.store.ts
import { SqlResilienceStore, type SqlDriver, type SqlResilienceStoreOptions } from '@dudousxd/nestjs-resilience';
import type { DataSource } from 'typeorm';

export class TypeOrmResilienceStore extends SqlResilienceStore {
  constructor(ds: DataSource, opts: SqlResilienceStoreOptions = {}) {
    super(typeOrmDriver(ds), opts);
  }
}

function typeOrmDriver(ds: DataSource): SqlDriver {
  return {
    placeholders: 'numbered', // Postgres → $1, $2, …
    transaction: (body) =>
      ds.transaction((em) =>
        body({
          run: async (sql, params) => { await em.query(sql, params); },
          all: (sql, params) => em.query(sql, params) as Promise<unknown[]>,
        }),
      ),
    read: (sql, params) => ds.query(sql, params) as Promise<unknown[]>,
    exec: async (sql) => { await ds.query(sql); },
  };
}

Atomicity lives in your transaction. SqlResilienceStore issues a SELECT … FOR UPDATE inside the callback and relies on it holding a row lock for the duration of the transaction — that's what hands the half-open probe to exactly one instance. If your engine can't lock the row (or serialize the transaction), the store is not safe under concurrency.

The DDL is exposed as CIRCUITS_DDL (a plain, dialect-agnostic CREATE TABLE IF NOT EXISTS string) and applied by ensureSchema(). Call it once on boot, or feed CIRCUITS_DDL to your migration tool instead:

import { CIRCUITS_DDL, SqlResilienceStore } from '@dudousxd/nestjs-resilience';

const store = new SqlResilienceStore(myDriver);
await store.ensureSchema();      // runs CIRCUITS_DDL — safe to call on every startup
// …or in a migration: await queryRunner.query(CIRCUITS_DDL);

Path B — implement ResilienceStore directly (non-SQL engines)

For a key/value or document engine, implement the interface yourself — but don't reinvent the breaker logic. The core package exports the state machine as three pure, serializable primitives, so your store only handles storage and atomicity:

ExportWhat it is
CircuitStateThe plain, serializable unit every store persists: { status, failures, openUntil, probes }.
INITIAL_CIRCUIT_STATEFresh-circuit defaults (a brand-new key behaves as a closed circuit).
computeAdmit(prev, cfg, now)Pure "may I run?" decision → { state, admission }. No I/O.
computeRecord(prev, cfg, ok, probe, now)Pure "record this outcome" → { state, status }. No I/O.

The pattern is always load → compute → persist, wrapped in whatever atomicity your engine provides. This is exactly how the synchronous Drizzle/SQLite adapter works:

my-kv.store.ts
import {
  INITIAL_CIRCUIT_STATE, computeAdmit, computeRecord, systemClock,
  type Admission, type BreakerConfig, type CircuitSnapshot, type CircuitState,
  type CircuitStatus, type Clock, type ResilienceStore,
} from '@dudousxd/nestjs-resilience';

export class MyKvResilienceStore implements ResilienceStore {
  private readonly clock: Clock;
  constructor(private readonly engine: MyEngine, opts: { clock?: Clock } = {}) {
    this.clock = opts.clock ?? systemClock;
  }

  private load(key: string): CircuitState {
    const row = this.engine.get(key);
    return row ? (JSON.parse(row) as CircuitState) : { ...INITIAL_CIRCUIT_STATE };
  }

  async admit(key: string, cfg: BreakerConfig): Promise<Admission> {
    return this.engine.transaction(() => {            // ← your atomic section
      const { state, admission } = computeAdmit(this.load(key), cfg, this.clock.now());
      this.engine.set(key, JSON.stringify(state));
      return admission;
    });
  }

  async record(key: string, cfg: BreakerConfig, ok: boolean, probe: boolean): Promise<CircuitStatus> {
    return this.engine.transaction(() => {
      const { state, status } = computeRecord(this.load(key), cfg, ok, probe, this.clock.now());
      this.engine.set(key, JSON.stringify(state));
      return status;
    });
  }

  async snapshot(key: string): Promise<CircuitSnapshot> {
    const s = this.load(key);
    return { status: s.status, failures: s.failures, ...(s.openUntil ? { openUntil: s.openUntil } : {}) };
  }
}

Threading a Clock through (defaulting to systemClock) lets a FakeClock drive cooldowns instantly in tests. Whichever path you take, prove it against the shared contract suite — it's the same five-behaviour test every built-in adapter passes, including the concurrent-probe and lost-update checks.

On this page