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.
Quick Reference
Section titled “Quick Reference”| Adapter | Import | Stores | Best For |
|---|---|---|---|
cloudflareAdapter | stoma/adapters/cloudflare | KV / Durable Objects (rate limit), Cache API (cache), in-memory (circuit breaker) | Cloudflare Workers production |
memoryAdapter | stoma/adapters/memory | All in-memory | Development, testing, single-instance |
redisAdapter | stoma/adapters/redis | All Redis-backed | Multi-instance Node/Bun/Deno production |
postgresAdapter | stoma/adapters/postgres | All Postgres-backed | Already have Postgres, no Redis |
nodeAdapter | stoma/adapters/node | All in-memory | Node.js quick start |
bunAdapter | stoma/adapters/bun | Bun in-memory | Bun quick start |
denoAdapter | stoma/adapters/deno | Deno in-memory | Deno quick start |
Choosing an Adapter
Section titled “Choosing an Adapter” 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 memoryAdapterKey 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.
Cloudflare Workers
Section titled “Cloudflare Workers”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); },};Rate Limit Store Priority
Section titled “Rate Limit Store Priority”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: [/* ... */],});import { createClient } from "redis";import { createGateway } from "@homegrower-club/stoma";import { redisAdapter } from "@homegrower-club/stoma/adapters/redis";
const client = await createClient({ url: process.env.REDIS_URL }).connect();
const gateway = createGateway({ adapter: redisAdapter({ client: client as any, // node-redis v4 has a different SET signature setWithTTL: (c, k, v, ttl) => c.set(k, v, "EX", ttl), }), routes: [/* ... */],});Configuration
Section titled “Configuration”| Option | Default | Purpose |
|---|---|---|
client | required | Redis client instance |
prefix | "stoma:" | Key prefix for all Redis keys |
setWithTTL | ioredis-style | Override for libraries with different SET signatures |
stores | all enabled | Selectively disable stores: { rateLimit: false } |
waitUntil | none | Background work scheduler |
How It Works
Section titled “How It Works”- Rate limiting uses an atomic Lua script (
INCR+ conditionalEXPIRE) 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.
PostgreSQL
Section titled “PostgreSQL”Same pattern as Redis - provide any Postgres client that implements query(text, params). Works with pg, postgres.js, and similar libraries.
-
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) withIF NOT EXISTSso it’s safe to re-run. -
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: [/* ... */],});
Configuration
Section titled “Configuration”| Option | Default | Purpose |
|---|---|---|
client | required | Postgres client/pool instance |
tablePrefix | "stoma_" | Table name prefix |
stores | all enabled | Selectively disable stores: { cache: false } |
waitUntil | none | Background work scheduler |
Cleanup
Section titled “Cleanup”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 handlerconst 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();Memory (Development & Testing)
Section titled “Memory (Development & Testing)”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: [/* ... */],});Testing with TestAdapter
Section titled “Testing with TestAdapter”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 promisesCustom Adapters
Section titled “Custom Adapters”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.
What’s Next?
Section titled “What’s Next?”- Tutorial: Response Caching - caching with different adapters
- Testing Guide - TestAdapter for policy testing
- API: GatewayAdapter - full interface reference