Stores
Share circuit-breaker state across instances with a config-driven ResilienceStore — in-memory by default, or Lucid (SQL) / Redis drivers selected in config/resilience.ts 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
reset(key: string): Promise<void> // back to closed
}Four small types make up that contract:
interface BreakerConfig {
threshold: number // consecutive failures before the circuit opens
cooldownMs: number // how long it stays open before probing
halfOpenMax?: number // concurrent probes allowed in half-open (default 1)
}
interface Admission {
allow: boolean // may this call run at all? false → the breaker throws BrokenCircuitError
probe: boolean // is this call the half-open trial? hand it back to record()
status: CircuitStatus // the circuit's state at the moment of the decision
}
interface CircuitSnapshot {
status: CircuitStatus // 'closed' | 'open' | 'half-open'
failures: number // consecutive failures counted so far
openUntil?: number // epoch ms the cooldown expires; absent when closed
}Admission is the one to know, because it carries the distributed guarantee. admit() answers two questions at once: may I run (allow), and am I the single call elected to test a recovering dependency (probe). The breaker passes that probe flag straight back into record(), which is how the store can tell a trial run from ordinary traffic and decide whether to close the circuit or re-open it.
const admission = await store.admit('payments', { threshold: 5, cooldownMs: 30_000 })
if (!admission.allow) throw new BrokenCircuitError('payments') // fail fast, don't call the dependency
try {
const result = await callPayments()
await store.record('payments', cfg, true, admission.probe) // a successful probe closes the circuit
return result
} catch (err) {
await store.record('payments', cfg, false, admission.probe) // a failed probe re-opens it
throw err
}That is exactly what circuitBreaker does for you — you only write this loop when implementing a custom store or driving a store directly.
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 driver below enforces that against its real engine (a Lua script for Redis, a SELECT … FOR UPDATE transaction for SQL) and is verified by a shared contract suite.
Config-driven stores
All three stores ship in the core package and are selected in config/resilience.ts with the stores factory. Pick a default and list the stores you use under stores:
import { defineConfig, stores } from '@adonis-agora/resilience'
export default defineConfig({
default: 'memory',
stores: {
memory: stores.memory(), // no peer dependency
lucid: stores.lucid({ connection: 'pg' }), // requires @adonisjs/lucid
redis: stores.redis({ connection: 'main' }), // requires @adonisjs/redis + ioredis
},
})default decides which store the breaker uses; the other entries stay reachable by name through circuitStore('lucid').
Every listed store is built at boot
Listing a store is not free. Each stores.* call returns a lazy thunk, but the provider builds every entry in the stores map when it first resolves the service — not just the one default points at — and each Lucid / Redis thunk imports its peer dependency as it is built. The config above therefore needs @adonisjs/lucid, @adonisjs/redis and ioredis installed, even though default is memory; without them the app fails to boot on the missing import.
List only the stores whose peers you have installed. A peer you never list is never imported, which is what keeps those packages optional.
@adonisjs/lucid, @adonisjs/redis and ioredis are optional peer dependencies of @adonis-agora/resilience. Install the ones every store you list needs — e.g. npm i @adonisjs/redis ioredis for a stores.redis() entry.
In-memory (default)
stores.memory() keeps circuit state in the process — each instance trips its own breaker. JavaScript's single-threaded run-to-completion model gives it atomicity for free, and it needs no peer dependency. It is the default the published config ships with.
import { defineConfig, stores } from '@adonis-agora/resilience'
export default defineConfig({
default: 'memory',
stores: { memory: stores.memory() },
})stores.memory({ clock }) accepts an optional Clock for tests. The underlying InMemoryResilienceStore is also exported if you want to construct one by hand.
new InMemoryResilienceStore(clock?: Clock, opts?: { maxEntries?: number; ttlMs?: number })Both are unbounded by default, matching prior behavior. But if any part of a circuit key is caller-influenced — the tenant-scoping pattern is the common case — an attacker who can cause many distinct keys to be admitted/recorded grows this map without limit, since only an explicit reset(key) ever removes an entry. Set one or both to bound it:
import { defineConfig, stores } from '@adonis-agora/resilience'
export default defineConfig({
default: 'memory',
stores: {
memory: stores.memory({ maxEntries: 10_000, ttlMs: 30 * 60_000 }),
},
})maxEntriesevicts the least-recently-used key once the store holds more than this many — recommended wheneverkeyis tenant/user-scoped.ttlMstreats a key as expired — and recreates it fresh — once it hasn't been touched (admit/record/snapshot) for this long. It is checked lazily on the next access to that same key rather than via a background sweep, so pair it withmaxEntriesif you also need to bound memory held by keys nobody touches again.
Distributed stores
npm i @adonisjs/lucidstores.lucid() builds a thin SQL driver over the Lucid connection and feeds the agnostic SqlResilienceStore, so all breaker semantics live in core:
import { defineConfig, stores } from '@adonis-agora/resilience'
export default defineConfig({
default: 'lucid',
stores: {
lucid: stores.lucid({ connection: 'pg' }), // omit `connection` for the default
},
})By default the circuit table is created lazily on first use via the idempotent CREATE TABLE IF NOT EXISTS — no extra setup. The driver auto-detects the dialect: it uses SELECT … FOR UPDATE row locks on Postgres/MySQL and strips them on SQLite (which serialises transaction writers itself).
interface LucidStoreConfig {
connection?: string // @adonisjs/lucid connection name — default connection if omitted
clock?: Clock
autoCreateSchema?: boolean // default true — set false if you own the schema via a migration
useRowLocks?: boolean // override dialect auto-detection
}Building it by hand
stores.lucid() resolves the connection for you. When you need the store outside the config — in a test, in a script, or to hand to a circuitBreaker directly — construct it yourself with the exported factory (or the LucidResilienceStore class it wraps; they are equivalent):
import { lucidResilienceStore, LucidResilienceStore } from '@adonis-agora/resilience'
import db from '@adonisjs/lucid/services/db'
const store = lucidResilienceStore(db, { autoCreateSchema: false })
// identical:
const same = new LucidResilienceStore(db, { autoCreateSchema: false })
// a specific connection rather than the default one:
const pg = lucidResilienceStore(db.connection('pg'))The first argument is the database itself, not a config object. It is typed as LucidDatabase — a structural slice of Lucid's Database, not the concrete class:
interface LucidQueryClient {
rawQuery(sql: string, bindings?: readonly unknown[]): Promise<unknown>
}
interface LucidDatabase extends LucidQueryClient {
transaction<T>(callback: (trx: LucidQueryClient) => Promise<T>): Promise<T>
connection?(name?: string): { dialect?: { name?: string } } // used only to detect the dialect
}Because it is structural, the Lucid Database service and a db.connection(name) both satisfy it — and so does a stub with those two methods, which is what makes the store testable without a database. The optional connection() is only read to auto-detect the dialect; supply useRowLocks explicitly if it isn't there.
Lucid: migration over auto-create
Auto-create is convenient, but in production you usually own the schema with a migration. Disable auto-create and run the DDL from a migration instead. The package exports CIRCUITS_DDL for that, plus a helper that runs it:
Create the migration
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
async up() {
this.schema.createTable('resilience_circuits', (table) => {
table.text('key').primary()
table.text('status').notNullable().defaultTo('closed')
table.integer('failures').notNullable().defaultTo(0)
table.bigInteger('open_until').notNullable().defaultTo(0)
table.integer('probes').notNullable().defaultTo(0)
})
}
async down() {
this.schema.dropTable('resilience_circuits')
}
}The columns match CIRCUITS_DDL — key, status, failures, open_until, probes. If you prefer the raw DDL string, run this.schema.raw(CIRCUITS_DDL) instead.
Skip the runtime CREATE TABLE
import { defineConfig, stores } from '@adonis-agora/resilience'
export default defineConfig({
default: 'lucid',
stores: {
lucid: stores.lucid({ connection: 'pg', autoCreateSchema: false }),
},
})ensureResilienceSchema
ensureResilienceSchema(db: LucidQueryClient): Promise<void>Runs CIRCUITS_DDL against the client you give it. It is idempotent (CREATE TABLE IF NOT EXISTS), so calling it repeatedly is harmless — handy in a test setup or a one-off script where a migration would be overkill:
import { ensureResilienceSchema } from '@adonis-agora/resilience'
import db from '@adonisjs/lucid/services/db'
await ensureResilienceSchema(db)The parameter is LucidQueryClient — anything with a rawQuery method — so it is not limited to the Database service. A named connection works, and so does a transaction client, which lets you create the table inside a migration's transaction:
await ensureResilienceSchema(db.connection('pg'))
await db.transaction(async (trx) => {
await ensureResilienceSchema(trx) // a trx satisfies LucidQueryClient
await seedCircuits(trx)
})In production prefer the migration above: it gives you a down(), and it keeps the table under the same review and rollout process as the rest of your schema. ensureResilienceSchema and autoCreateSchema: true exist so that getting started needs no setup at all.
Bring your own engine
This is the quick version — reusing the SQL base for another relational driver. For the full picture (reusing the pure state machine for a non-SQL engine, the redisResilienceStore factory, implementing ResilienceStore from scratch, and wiring it through ResilienceService.circuitStore()), see Building a custom store.
Under the hood the Lucid store is just a thin SqlDriver over the agnostic SqlResilienceStore exported from core. To back the breaker with any other SQL driver, implement SqlDriver and hand it to SqlResilienceStore, then wire it as an explicit store:
import { SqlResilienceStore, type SqlDriver } from '@adonis-agora/resilience'
const driver: SqlDriver = {
placeholders: 'numbered', // '$1, $2' (Postgres) or 'positional' for '?'
lockRows: true, // false on engines without SELECT … FOR UPDATE (e.g. SQLite)
transaction: (body) => pool.transaction((tx) => body({
run: (sql, params) => tx.query(sql, params).then(() => undefined),
all: (sql, params) => tx.query(sql, params).then((r) => r.rows),
})),
read: (sql, params) => pool.query(sql, params).then((r) => r.rows),
exec: (sql) => pool.query(sql).then(() => undefined),
}
const store = new SqlResilienceStore(driver)
await store.ensureSchema() // runs CIRCUITS_DDLPass a fully-constructed store as store in config/resilience.ts (it takes precedence over default/stores):
export default defineConfig({ store })SqlResilienceStore owns the atomic load→compute→persist cycle and the dialect-aware statements, so breaker semantics live in exactly one place. For a non-SQL engine (or fully custom storage), implement the four ResilienceStore methods directly.
All stores share one type, BreakerConfig (threshold, cooldownMs, halfOpenMax?) — the same options you pass to circuitBreaker. Bring your own engine by implementing the store and validating it against the contract suite — if it passes, it behaves like the built-ins under concurrency.
Service & Config
Register named policies in config/resilience.ts, let the provider bind a singleton ResilienceService, and run policies + inspect circuits through the container.
Building a custom store
Back the circuit breaker with any engine — reuse the SQL base and a tiny SqlDriver, reuse the pure state machine (computeAdmit / computeRecord), or implement ResilienceStore from scratch — then wire it through the ResilienceService.