@paykernel/store-redis is optional coordination: lease-aware idempotency, webhook inbox, and reconciliation against shared Redis, Valkey, or Upstash. Claims are atomic Lua (never get-then-set). PostgreSQL alone can satisfy the same store contracts.
Version 0.1.1 — published on npm as @paykernel/store-redis. Install: bun add @paykernel/store-redis.
Install
bun add @paykernel/store-redis
# optional drivers (pick a binding):
bun add ioredis
# or
bun add redis
# or
bun add @upstash/redis
# Bun native Redis needs no npm peer — use /bunRoot entry never statically imports ioredis, redis, @upstash/redis, or Bun Redis. This package does not depend on @paykernel/sql-foundation (Redis is not relational).
Quick start (driver-free root)
import {
createRedisIdempotencyStore,
type RedisCommandPort,
} from "@paykernel/store-redis";
const port: RedisCommandPort = {
async send(command, args) {
return client.send(command, args);
},
};
const store = createRedisIdempotencyStore({ port });
const r = await store.reserve({
key: "pay_123",
fingerprint: "fp",
owner: "worker-1",
leaseMs: 30_000,
});createRedisStores({ port }) bundles all three contracts. There is no migrate step (no DDL).
Subpaths
| Subpath | Package / runtime | Notes |
|---|---|---|
/bun |
Bun.RedisClient |
Prefer injected client. createBunRedisFromEnv / URL are convenience only. Rejects Cluster, Sentinel, and clusterKeys |
/upstash |
@upstash/redis |
HTTP REST; EVAL is still server-side Lua |
/ioredis |
ioredis |
Prefer enableOfflineQueue: false (IOREDIS_STORE_CLIENT_DEFAULTS). Cluster + keys.clusterKeys |
/node-redis |
redis |
Prefer disableOfflineQueue: true (NODE_REDIS_STORE_CLIENT_DEFAULTS). Cluster + hash tags |
import {
createRedisStoresFromBun,
} from "@paykernel/store-redis/bun";
const stores = createRedisStoresFromBun({ redis: { client } });
// convenience only:
// createRedisStoresFromBun({ redis: { url: process.env.REDIS_URL! } });
// createRedisStoresFromBun({ redis: { fromEnv: true } });fromEnv reads PAYMENTS_SDK_REDIS_URL / REDIS_URL / VALKEY_URL.
import Redis from "ioredis";
import { createRedisStoresFromIoredis } from "@paykernel/store-redis/ioredis";
const client = new Redis(process.env.PAYMENTS_SDK_REDIS_URL!, {
enableOfflineQueue: false,
});
const stores = createRedisStoresFromIoredis({ client });import { Redis } from "@upstash/redis";
import { createRedisStoresFromUpstash } from "@paykernel/store-redis/upstash";
const redis = Redis.fromEnv();
const stores = createRedisStoresFromUpstash({ client: redis });Bun + Cluster or Sentinel → do not use /bun; use /ioredis or /node-redis with clusterKeys so multi-key Lua (HASH + ZSET) hash-tags co-locate. Default clusterKeys: false is standalone-only; Cluster without hash tags fails CROSSSLOT.
Guarantees
REDIS_STORAGE_ADAPTER_MANIFEST: coordinationScope: "multi-host", durability: "configuration-dependent", claims: "strong", supportsTransactions: false (Lua atomicity, not MULTI/EXEC as the claim path).
Four durability distinctions (do not blur them):
| # | Distinction | Meaning |
|---|---|---|
| 1 | Coordination-safe | Multi-worker claims while Redis is up and shared |
| 2 | Durable across process restart | Keys still in the running Redis service |
| 3 | Durable across Redis restart | Only with correct AOF/RDB or managed persistence |
| 4 | Only-audit-store | Not recommended; prefer hybrid SQL |
Do not use Pub/Sub for webhook delivery correctness or retries. Disable / control offline command queues for correctness-critical ops. Injectable now is Lua ARGV (FakeClock works).
Completed idempotency fences never EXPIRE (retentionTtlMs ignored for complete). Use deleteExpired for cleanup. Invalid dueAt/retryAt ISO fails closed (StoreInvalidSchemaError) — never maps to epoch 0.
Hybrid (optional, app layer)
Redis for claims/leases + SQL for long-term audit. Core/webhooks never import either adapter.
import {
createRedisIdempotencyStoreFromBun,
createRedisWebhookInboxStoreFromBun,
} from "@paykernel/store-redis/bun";
import {
createPostgresReconciliationStoreFromPg,
migratePostgresAdapter,
createPgPostgresExecutor,
} from "@paykernel/store-postgres/pg";
import { Pool } from "pg";
const idempotency = createRedisIdempotencyStoreFromBun({
redis: { fromEnv: true },
});
const webhookInbox = createRedisWebhookInboxStoreFromBun({
redis: { fromEnv: true },
});
const pool = new Pool({ connectionString: process.env.PAYMENTS_SDK_PG_URL });
const executor = createPgPostgresExecutor(pool);
await migratePostgresAdapter(executor);
const reconciliation = createPostgresReconciliationStoreFromPg({ client: pool });No-Redis path: @paykernel/store-postgres createPostgresStoresFromPg implements all three contracts.
Failure paths
| Event | Behavior |
|---|---|
| Crash mid-handler | Record stays leased until expiry; peer reclaims with new token + generation++ |
| Stale mutator | StoreLeaseLostError — not a payment failure |
| Redis down | StoreUnavailableError / StoreTimeoutError (retryable: true where mapped) |
| Indeterminate row | reserve returns indeterminate; no new lease. Do not createPayment again |
| Eviction / no AOF | Terminals and leases can vanish — do not call this a durable audit trail |
| Bun Cluster config | Binding rejects Cluster/Sentinel/clusterKeys |
Never fulfill in onWebhookVerified. Claim the inbox first; fulfill only on paid rematched events. See Webhooks.