Skip to content

Guide: Runtime Adapters

Adapters bridge Stoma’s policies to your runtime’s storage and background-work primitives. Rate limiting needs a counter store, circuit breaking needs state persistence, and caching needs a response store. The adapter provides all three - plus optional waitUntil for background work and dispatchBinding for service binding dispatch.

AdapterImportStoresBest For
cloudflareAdapterstoma/adapters/cloudflareKV / Durable Objects (rate limit), Cache API (cache), in-memory (circuit breaker)Cloudflare Workers production
memoryAdapterstoma/adapters/memoryAll in-memoryDevelopment, testing, single-instance
redisAdapterstoma/adapters/redisAll Redis-backedMulti-instance Node/Bun/Deno production
postgresAdapterstoma/adapters/postgresAll Postgres-backedAlready have Postgres, no Redis
nodeAdapterstoma/adapters/nodeAll in-memoryNode.js quick start
bunAdapterstoma/adapters/bunBun in-memoryBun quick start
denoAdapterstoma/adapters/denoDeno in-memoryDeno quick start
Cloudflare Workers?
/ \
yes no
/ \
cloudflareAdapter Need shared state
across instances?
/ \
yes no
/ \
Have Redis? memoryAdapter
/ \ (or nodeAdapter /
yes no bunAdapter /
/ \ denoAdapter)
redisAdapter Have Postgres?
/ \
yes no
/ \
postgresAdapter memoryAdapter

Key decision: if you run multiple instances behind a load balancer, rate limit counters and circuit breaker state must be shared. Use redisAdapter or postgresAdapter. For single-instance deployments or development, memoryAdapter (or a runtime-specific wrapper) is all you need.

The Cloudflare adapter uses platform-native primitives for each store:

  • Rate limiting: Durable Objects (strongly consistent) or KV (eventually consistent)
  • Caching: Cache API (caches.default)
  • Circuit breaker: In-memory (per-isolate - acceptable since Workers are short-lived)
import { createGateway } from "@homegrower-club/stoma";
import { cloudflareAdapter } from "@homegrower-club/stoma/adapters/cloudflare";
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const adapter = cloudflareAdapter({
rateLimitKv: env.RATE_LIMIT_KV, // KVNamespace binding
// rateLimitDo: env.RATE_LIMIT_DO, // or DurableObjectNamespace (preferred)
cache: caches.default,
executionCtx: ctx, // enables waitUntil
env, // enables dispatchBinding for service bindings
});
const gateway = createGateway({
adapter,
routes: [/* ... */],
});
return gateway.app.fetch(request, env, ctx);
},
};

If you pass both rateLimitDo and rateLimitKv, Durable Objects wins. DO provides strong consistency (exact counts), while KV is eventually consistent (counts may drift slightly under high concurrency). For most APIs, KV is sufficient and simpler to set up.

Zero runtime dependencies - you provide any Redis client that satisfies the RedisClient interface (4 methods: get, set, del, eval). Works with ioredis, node-redis, and similar libraries.

import Redis from "ioredis";
import { createGateway } from "@homegrower-club/stoma";
import { redisAdapter } from "@homegrower-club/stoma/adapters/redis";
const redis = new Redis(process.env.REDIS_URL);
const gateway = createGateway({
adapter: redisAdapter({ client: redis }),
routes: [/* ... */],
});
OptionDefaultPurpose
clientrequiredRedis client instance
prefix"stoma:"Key prefix for all Redis keys
setWithTTLioredis-styleOverride for libraries with different SET signatures
storesall enabledSelectively disable stores: { rateLimit: false }
waitUntilnoneBackground work scheduler
  • Rate limiting uses an atomic Lua script (INCR + conditional EXPIRE) for race-free counting in a single round trip.
  • Circuit breaker stores state as JSON strings with a 24-hour TTL (zombie key protection).
  • Cache stores responses as JSON envelopes (base64-encoded body + headers + status) with TTL-based expiry.

Same pattern as Redis - provide any Postgres client that implements query(text, params). Works with pg, postgres.js, and similar libraries.

  1. Create the schema tables (one-time setup):

    import { POSTGRES_SCHEMA_SQL } from "@homegrower-club/stoma/adapters/postgres";
    await pool.query(POSTGRES_SCHEMA_SQL);

    This creates three tables (stoma_rate_limits, stoma_circuit_breakers, stoma_cache) with IF NOT EXISTS so it’s safe to re-run.

  2. Create the adapter:

    import { Pool } from "pg";
    import { createGateway } from "@homegrower-club/stoma";
    import { postgresAdapter } from "@homegrower-club/stoma/adapters/postgres";
    const pool = new Pool({ connectionString: process.env.DATABASE_URL });
    const gateway = createGateway({
    adapter: postgresAdapter({ client: pool }),
    routes: [/* ... */],
    });
OptionDefaultPurpose
clientrequiredPostgres client/pool instance
tablePrefix"stoma_"Table name prefix
storesall enabledSelectively disable stores: { cache: false }
waitUntilnoneBackground work scheduler

Both the rate limit and cache stores accumulate expired rows. Call cleanup() periodically:

import { postgresAdapter } from "@homegrower-club/stoma/adapters/postgres";
const adapter = postgresAdapter({ client: pool });
// In a cron job or scheduled handler
const rlStore = adapter.rateLimitStore as import("@homegrower-club/stoma/adapters/postgres").PostgresRateLimitStore;
const cacheStore = adapter.cacheStore as import("@homegrower-club/stoma/adapters/postgres").PostgresCacheStore;
await rlStore.cleanup();
await cacheStore.cleanup();

In-memory stores are perfect for development, testing, and single-instance deployments. All data lives in the process and is lost on restart.

import { createGateway } from "@homegrower-club/stoma";
import { memoryAdapter } from "@homegrower-club/stoma/adapters/memory";
const gateway = createGateway({
adapter: memoryAdapter(),
routes: [/* ... */],
});

For tests, the SDK provides TestAdapter which extends memoryAdapter() with a waitAll() method to flush background promises:

import { createPolicyTestHarness } from "@homegrower-club/stoma/sdk";
const { request, adapter } = createPolicyTestHarness(myPolicy());
const res = await request("/test");
await adapter.waitAll(); // flush waitUntil promises

The GatewayAdapter interface is intentionally minimal - implement only what your policies need:

import type { GatewayAdapter } from "@homegrower-club/stoma";
function myAdapter(): GatewayAdapter {
return {
rateLimitStore: new MyRateLimitStore(),
// circuitBreakerStore: omit if you don't use circuitBreaker()
// cacheStore: omit if you don't use cache()
waitUntil: (p) => backgroundQueue.push(p),
};
}

Policies that require a store (rate limit, circuit breaker, cache) read it from adapter at request time. If the store is undefined, the policy degrades gracefully via safeCall - a missing store never crashes a request.