Distributed stores
Attach a Redis, Cloudflare Workers KV, or Deno KV StitchStore so a stitch's throttle, sessions, and cache become fleet-wide — same call site, no backend in core. Includes the capability table that decides which store fits.
A StitchStore is the one seam that turns
the process-local pieces of a stitch — its
rate-limit counters, its
auth sessions and tokens, and its response
cache — into fleet-wide state, with no change to the call site. Core ships
no backend; you attach one of these drivers to a seam
and every worker shares the same counters and sessions.
Each driver is a thin peer-dependency (or zero-dependency) package that imports no
client of its own — it runs on a small structural surface, so the client you
already use is a drop-in, and a test double is too. Each one runs the same
conformance.store kit from stitchapi/testing, which checks the capability
groups a store implements: every store passes the base group, and Redis and Deno KV
pass the counter, pacing and lease groups as well.
Which store?
The deciding axis is atomicity. Shared cache and sessions need only get /
set, which every backend has. A fleet-wide throttle needs an atomic
read-modify-write in the store — the pacing cell for rate, the lease pair for
concurrency — and an eventually-consistent backend can't offer one.
| Package | Runtime | Fleet-wide throttle | Shared cache & sessions | TTL unit |
|---|---|---|---|---|
@stitchapi/redis | Node (TCP) and edge (Upstash HTTP) | ✅ Lua scripts, one round trip each | ✅ | ms |
@stitchapi/deno-kv | Deno / Deno Deploy · Node & Bun via @deno/kv | ✅ atomic compare-and-set | ✅ | ms |
@stitchapi/cloudflare-kv | Cloudflare Workers / Pages (Web-API only) | ❌ per isolate — pair with a Durable Object | ✅ | s (60s min) |
Reach for Redis on Node — or when you want one store that also runs on the edge via Upstash; Deno KV on Deno Deploy, where the same handle is replicated globally; Cloudflare Workers KV when you're on Workers and need shared cache and sessions (and pair it with a Durable Object if you also need a throttle).
Capabilities
StitchStore requires only get and set. Every other verb is a capability: a
feature uses it when the store has it, and degrades in a known way when it doesn't.
Nothing is configured — the throttle and the cache look at which verbs the store
has.
| Store | get / set | increment | reserve | lease / release |
|---|---|---|---|---|
memoryStore() (core default, one process) | ✅ | ✅ | ✅ | ✅ |
@stitchapi/redis (bundled adapters) | ✅ | ✅ | ✅ | ✅ |
@stitchapi/deno-kv | ✅ | ✅ | ✅ | ✅ |
@stitchapi/cloudflare-kv | ✅ | — | — | — |
asyncStorageStore / expoSecureStore (on-device) | ✅ | ✅ | — | — |
A custom RedisDriver must implement increment; reserve and the lease pair are
optional there too, and redisStore exposes only what the driver has.
What each capability buys, and what happens without it:
| Capability | Used by | Without it |
|---|---|---|
get / set | cache, auth sessions and tokens, circuit, all | required |
reserve | throttle.rate — one fleet-wide cursor | rate falls back to increment: fleet-shared slots, but a fleet that starts mid-window can run at up to rate × workers until it catches up |
increment | throttle.rate, only when there's no reserve | with no reserve either, rate paces per process |
lease / release | throttle.concurrency — fleet-wide slots | concurrency caps per process |
Bulk cache invalidation needs no capability: it writes a fresh generation with
set, so it works on every store.
A limit that ends up per-process says so. On its first call the throttle emits one
info event, topic throttle.per-process, naming the limit and the missing verbs
— for example rate: the store has no reserve or increment. Route events to a
trace sink to see it; it fires once per
throttle, not per call. loggerSink logs info events at debug (and
@stitchapi/pino and @stitchapi/nest leave them out), so to see this one at the
usual log level
raise its topic to warn.
Redis — @stitchapi/redis
npm install @stitchapi/redis@rc stitchapi@rcAttach a redisStore to a seam and its throttle budget and auth sessions become
fleet-wide. The package imports no Redis client — wrap the one you already run with
the matching adapter (ioredis and redis are optional peers; @upstash/redis is
matched structurally, so it never weighs on Node-only users):
import { fromIoredis, redisStore } from '@stitchapi/redis';
import Redis from 'ioredis';
import { seam } from 'stitchapi';
const api = seam({
store: redisStore(fromIoredis(new Redis(process.env.REDIS_URL))),
});The same store runs on the edge with fromUpstash — Upstash speaks Redis over
HTTP, so the identical atomic INCR + EXPIRE logic works from a Vercel or
Cloudflare function with no TCP socket:
import { fromUpstash, redisStore } from '@stitchapi/redis';
import { Redis } from '@upstash/redis';
const store = redisStore(fromUpstash(Redis.fromEnv()));increment must be atomic and set the key's TTL only when it creates the counter
— the bundled adapters do this in a single Lua EVAL (INCR, then PEXPIRE only
when the value is 1), so a rate window can't slide forever and a crash can't
strand an immortal counter. With a shared store the rate limiter is
even-spaced across the fleet — see
distributed throttle for the exact
pacing semantics. Namespace keys with keyPrefix to share one Redis with other
data:
redisStore(fromIoredis(client), { keyPrefix: 'myapp:' });Cloudflare Workers KV — @stitchapi/cloudflare-kv
npm install @stitchapi/cloudflare-kv@rc stitchapi@rcWeb-API only (no node:*), so it runs in a Worker, in Pages Functions, and in any
other edge runtime. Bind a KV namespace and the read-heavy halves of a stitch —
cache and shared sessions/tokens — become fleet-wide across every isolate:
import { cloudflareKvStore } from '@stitchapi/cloudflare-kv';
import { seam } from 'stitchapi';
export default {
async fetch(req, env) {
const api = seam({ store: cloudflareKvStore(env.MY_KV) });
return handle(api, req);
},
};Workers KV is a get / set store. It is last-write-wins get / put
/ delete with no atomic read-modify-write, so cloudflareKvStore
implements none of the optional capabilities — no increment, reserve or
lease pair. Cache (bulk invalidation included) and sessions work fully. A
throttle over it paces per isolate and announces that once with a
throttle.per-process event. For a fleet-wide throttle at the edge, back it
with a Durable Object
— a single writer, strongly consistent — or run @stitchapi/redis over
Upstash's HTTP API.
KV is also eventually consistent: a write can take about 60 seconds to reach
every location. That includes a cache bulk invalidation (stitch.cache.invalidate(),
seam.invalidate()), so for up to a minute a reader elsewhere may still serve the
entries you just invalidated. Exact invalidation (stitch.invalidate(input)) is a
set too, with the same lag.
Workers KV also allows one write per second to the same key. A bulk
invalidation writes one generation key, so rapid back-to-back invalidate() calls
(a loop, a webhook burst) can be rejected with a 429 — invalidate once per
change, or debounce.
KV's expirationTtl is in seconds with a 60-second minimum; the store
reconciles that with the contract's millisecond TTLs (ttl →
max(60, ceil(ttl / 1000)) s), so a value asked to live 5s lives 60s (harmless
for caches and sessions). set(key, undefined) deletes; keyPrefix namespaces.
Deno KV — @stitchapi/deno-kv
npm install @stitchapi/deno-kv@rc stitchapi@rcZero runtime dependencies — it never touches the Deno global, running on a
structural DenoKvLike surface that a real Deno.Kv satisfies as-is. On Deno
Deploy the same handle is replicated globally, so a stitch's state is edge-native
for free:
import { denoKvStore } from '@stitchapi/deno-kv';
import { seam } from 'stitchapi';
const api = seam({ store: denoKvStore(await Deno.openKv()) });The same package runs on Node or Bun through the
@deno/kv npm package:
import { openKv } from '@deno/kv';
import { denoKvStore } from '@stitchapi/deno-kv';
import { seam } from 'stitchapi';
const api = seam({ store: denoKvStore(await openKv(process.env.DENO_KV_URL)) });Deno KV has no native INCR, so increment is an atomic compare-and-set loop
(read value + versionstamp, then atomic().check(...).set(...).commit(),
retrying if another isolate raced) — N concurrent increments net exactly +N. The
TTL (expireIn, already in milliseconds) is set only on the increment that
creates the counter, so a busy window never resets. keyPrefix namespaces every
key for sharing one KV database.
See also
Batch downloads
downloadAll and DownloadManager sequence a list of downloads over core's download surface — FIFO admission under a concurrency ceiling, per-item settling, cancellation, aggregate progress with an ETA, an idle timeout for stalled transfers, and opt-in dedupe.
JSON Schema
JsonSchema.adapt turns a runtime-discovered JSON Schema into a StitchSchema (a Standard Schema) a stitch accepts — validated with an engine you supply, so it ships none of its own.