Redis

A typed Redis client built on Bun's native RedisClient with pub/sub, JSON helpers, multi-connection support, and auto-pipelining.

Overview

@tekir/redis wraps Bun's built-in RedisClient with a typed, prefix-aware API. Use it directly via #services: all methods work on the default connection. Call redis.connection(name) to switch to a named connection.

bun add @tekir/redis
services.ts
import { service } from '@tekir/core'
import type { RedisManager } from '@tekir/redis'

export const redis = service<RedisManager>('redis')

Register RedisProvider in your kernel:

start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { RedisProvider } from '@tekir/redis'

export default function({ app }: TekirApp) {
  app.registerAll([RedisProvider])
}

Configuration

Create config/redis.ts. The only required value is a connection URL.

.env
REDIS_URL=redis://localhost:6379
# With password:
# REDIS_URL=redis://:password@localhost:6379
# With TLS (Upstash, etc.):
# REDIS_URL=rediss://default:token@host:6380
config/redis.ts
import env from '#env'
import type { RedisConfig } from '@tekir/redis'

export default {
  url:    env.REDIS_URL,
  prefix: 'myapp:'            // optional key prefix
} satisfies RedisConfig

Key Prefix

Setting prefix prepends a namespace to every key operation. Your application code never includes the prefix; it is applied transparently.

// All key operations automatically prepend the configured prefix.
// Set prefix in config/redis.ts: { prefix: 'myapp:' }
import { redis } from '#services'

await redis.set('user:1', 'Alice')
// Stored in Redis as: myapp:user:1

await redis.get('user:1')
// Reads: myapp:user:1

// The prefix is transparent: you never write it in your application code.

Multiple Connections

For separate Redis instances (cache, queue, pub/sub), use the connections map. Each connection is lazy-initialized on first use.

config/redis.ts
import env from '#env'
import type { RedisConfig } from '@tekir/redis'

export default {
  default: 'main',
  connections: {
    main: {
      url: env.REDIS_URL,
      prefix: 'myapp:'
    },
    cache: {
      url: env.REDIS_CACHE_URL,
      prefix: 'cache:'
    },
    queue: {
      url: env.REDIS_QUEUE_URL,
      prefix: 'queue:'
    }
  }
} satisfies RedisConfig
import { redis } from '#services'

// Default connection: no need to call connection()
await redis.set('key', 'value')
await redis.get('key')

// Named connection
await redis.connection('cache').set('hot:data', 'value')
await redis.connection('queue').send('LPUSH', ['jobs', 'payload'])

String Operations

The core key/value methods mirror Redis string commands. All methods return Promises.

import { redis } from '#services'

// set(key, value): store a string or number
await redis.set('app:version', '1.0.0')
await redis.set('counter', 0)

// get(key): retrieve a string, or null if missing
const version = await redis.get('app:version')  // '1.0.0'
const missing = await redis.get('no:such:key')  // null

// del(...keys): delete one or more keys
await redis.del('app:version')
await redis.del('key1', 'key2', 'key3')

// exists(key): check if a key exists
const exists = await redis.exists('counter')  // true

// incr / decr: atomic integer operations
await redis.set('visits', 0)
await redis.incr('visits')   // 1
await redis.incr('visits')   // 2
await redis.decr('visits')   // 1

// expire(key, seconds): set a TTL
await redis.expire('counter', 3600)

// ttl(key): remaining TTL in seconds (-1 = no expiry, -2 = key missing)
const ttl = await redis.ttl('counter')  // e.g. 3598

Hash Operations

Hash commands let you store structured data under a single key without serializing the whole object to JSON on every write.

import { redis } from '#services'

// hget(key, field): get a single hash field
const name = await redis.hget('user:1', 'name')  // 'Alice' or null

// hmset(key, fields): set multiple fields (flat array: [field, value, ...])
await redis.hmset('user:1', ['name', 'Alice', 'email', '[email protected]', 'age', '30'])

// hmget(key, fields): get multiple fields in one round-trip
const [name2, email] = await redis.hmget('user:1', ['name', 'email'])
// ['Alice', '[email protected]']

// hincrby(key, field, increment): atomically increment a hash integer field
await redis.hincrby('user:1', 'loginCount', 1)  // e.g. 5

Set Operations

Redis sets are useful for tracking unique members: tags, online users, permissions.

import { redis } from '#services'

// sadd(key, ...members): add members to a set, returns count added
await redis.sadd('tags:post:1', 'typescript', 'bun', 'tekir')  // 3

// srem(key, ...members): remove members from a set
await redis.srem('tags:post:1', 'bun')  // 1

// sismember(key, member): check set membership
const has = await redis.sismember('tags:post:1', 'typescript')  // true

// smembers(key): get all members
const tags = await redis.smembers('tags:post:1')  // ['typescript', 'tekir']

JSON Helpers

setJSON() and getJSON() handle serialization automatically. Use them when the value is a JavaScript object or array.

import { redis } from '#services'

// setJSON(key, value, expireSeconds?): JSON.stringify then store
await redis.setJSON('session:abc', { userId: 42, role: 'admin' })
await redis.setJSON('cache:user:1', { name: 'Alice' }, 300)  // expires in 5 min

// getJSON<T>(key): retrieve and JSON.parse; returns null if missing or invalid
interface UserCache { name: string }
const user = await redis.getJSON<UserCache>('cache:user:1')
// { name: 'Alice' } or null

remember()

remember(key, seconds, callback) implements the cache-aside pattern: return the cached value if it exists, otherwise run the callback, store the result as JSON, and return it.

import { redis, db } from '#services'

// remember<T>(key, seconds, callback): cache-aside helper.
// Returns the cached value if present; otherwise runs callback,
// stores the result as JSON, and returns it.
const user = await redis.remember('user:1', 300, async () => {
  return await db.queryOne('SELECT * FROM users WHERE id = ?', [1])
})

// Subsequent calls within 300 s return the cached value without
// hitting the database.

Pub / Sub

Once a connection enters subscribe mode it cannot send regular commands. Use separate named connections for publishing and subscribing; configure them in config/redis.ts under connections.

import { redis } from '#services'

// Use separate connections for pub and sub: a subscribed connection
// cannot send regular commands. Set up 'pub' and 'sub' in config/redis.ts.
const pub = redis.connection('pub')
const sub = redis.connection('sub')

// subscribe(channel, callback): listen for messages
await sub.subscribe('notifications', (message, channel) => {
  console.log(`[${channel}] ${message}`)
})

// publish(channel, message): broadcast a message
await pub.publish('notifications', JSON.stringify({ type: 'ping', ts: Date.now() }))

// unsubscribe([channel]): stop listening (omit channel to unsubscribe all)
await sub.unsubscribe('notifications')

Raw Commands & Auto-Pipelining

Auto-pipelining (enabled by default) batches concurrent commands into a single round-trip. For commands not covered by the typed API, use send(command, args).

import { redis } from '#services'

// With auto-pipelining enabled (the default), concurrent awaited commands
// are automatically batched into a single round-trip: no code changes needed.
const [a, b, c] = await Promise.all([
  redis.get('key:a'),
  redis.get('key:b'),
  redis.get('key:c')
])

// send(command, args): execute any raw Redis command
const info = await redis.send('INFO', ['server'])
const keys = await redis.send('KEYS', ['myapp:*'])

// flushdb(): delete all keys in the current database (use with care)
await redis.flushdb()