kevy-client 1.7.10

Unified client for kevy — switch between in-process embedded and TCP server backends with a single URL.
Documentation

kevy-client

Unified KV facade for kevy — switch between in-process embedded and TCP server backends with one URL string. Pure Rust, zero crates.io runtime deps.

use kevy_client::Connection;

let mut conn = Connection::open(&std::env::var("MY_KEVY_URL").unwrap())?;
conn.set(b"hello", b"world")?;
assert_eq!(conn.get(b"hello")?, Some(b"world".to_vec()));
# Ok::<(), std::io::Error>(())

The same business code runs against any of:

MY_KEVY_URL Backend
mem:// in-process, in-memory only
file:///var/lib/myapp/ in-process, persistent (snapshot + AOF)
kevy://prod-cache:6379 TCP RESP server, kevy-native scheme
redis://prod-cache:6379/0 TCP RESP server, standard Redis URL
tcp://prod-cache:6379 TCP RESP server, raw (no SELECT round-trip)

Auth (redis://user:pass@…) and TLS (rediss://) are rejected up front — kevy ships without either; reach for stunnel / a proxy if you need them at the network boundary.

Install

cargo add kevy-client

Why a facade

Without this crate the typical downstream config has two parallel codepaths — one for "open an embedded Store with a path" and one for "validate a redis:// URL and open a TCP client". They share none of their setup, error handling, or test fixtures. Connection::open(url) replaces all of that with one builder.

The two backends were the kevy story anyway:

  • Embedded (kevy-embedded): in-process, zero network, builds for wasm32. Use it for embedded caches and single-process apps.
  • Server (kevy binary or Docker image): thread-per-core reactor + shared-nothing routing across cores + TCP RESP wire.

kevy-client ties both into one API so your app picks at runtime via environment variable / config file — develop against mem://, integration-test against file:///tmp/test, deploy against kevy://prod-cache:6379. No code change.

Command coverage (v1.7.0)

All five Redis data types plus generic-key ops, persistence, the full pub/sub cycle (including in-process embedded delivery), multi-key operations, scan/keys, and MULTI/EXEC/DISCARD transactions on the remote backend. Methods on Connection:

Connection / generic: ping, dbsize, flush, type_of, exists, del, expire, persist, ttl_ms.

String: set, set_with_ttl, get, incr, incr_by.

Hash: hset, hget, hdel, hlen, hgetall, hkeys, hvals.

List: lpush, rpush, lpop, rpop, llen, lrange.

Set: sadd, srem, smembers, scard, sismember.

Sorted set: zadd, zrem, zscore, zcard, zrange.

Multi-key (v1.4.0): mget, mset, sinter, sunion, sdiff.

Keyspace iteration (v1.4.0): keys(pattern), scan(cursor, pattern, count), randomkey. Embedded scan finishes in one round (any non-zero cursor returns empty); the remote backend honours the server's real cursor.

Transactions (v1.4.0 + v1.5.0 + v1.7.0, remote only): conn.multi()Transaction handle. Three queue surfaces — raw queue(&[verb, args...]), v1.5.0's typed builders (set, get, del, exists, incr, incr_by, mget, mset) that chain via &mut Self, and v1.7.0's typed reply cursor (exec_typed / exec_watched_typed returns a [TransactionReplies] with next_int / next_bulk / next_ok / next_array_of_bulks / expect_empty). Plus [Connection::watch] / [unwatch] for optimistic concurrency. Embedded returns ErrorKind::Unsupported — every Connection method already serialises on the embed mutex, so MULTI's locking guarantee maps to a no-op there.

// raw shape, unchanged from v1.4.0
let mut txn = conn.multi()?;
txn.queue(&[b"SET", b"counter", b"0"])?;
txn.queue(&[b"INCR", b"counter"])?;
let replies = txn.exec()?;  // Vec<kevy_resp::Reply>

// v1.5.0: typed builders chain with `?` directly
let mut txn = conn.multi()?;
txn.set(b"a", b"1")?
    .incr(b"counter")?
    .del(&[b"tmp"])?;
let replies = txn.exec()?;

// v1.5.0: WATCH-driven optimistic concurrency
conn.watch(&[b"counter"])?;
let mut txn = conn.multi()?;
txn.incr(b"counter")?;
match txn.exec_watched()? {
    Some(replies) => assert_eq!(replies.len(), 1),
    None         => { /* watched key changed — retry the whole block */ }
}

// v1.7.0: typed reply cursor — drop the manual `Reply` matches
let mut txn = conn.multi()?;
txn.set(b"a", b"1")?.incr(b"counter")?.get(b"a")?;
let mut r = txn.exec_typed()?;
r.next_ok()?;                                 // SET → +OK
let counter: i64 = r.next_int()?;             // INCR → :N
let prior: Option<Vec<u8>> = r.next_bulk()?;  // GET  → $… or nil
r.expect_empty()?;                            // arity gate

Pub/sub: Connection::publish for the producer side. The consumer side is Subscriber, a separate type with its own backing channel because subscribed connections cannot send normal commands per the RESP spec. v1.6.0 added recv_message (auto-skips (p)?(un)?subscribe ack frames and returns (channel, payload)), and psubscribe / punsubscribe for pattern subscriptions. v1.7.0 adds borrowing iterators — Subscriber::events() for every frame and Subscriber::messages() for ack-skipped (channel, payload) tuples — so consumers can write for msg in sub.messages() { … } instead of hand-rolling a loop { match sub.recv() … }. Both iterators terminate only on UnexpectedEof; transient errors (e.g. a read timeout) surface as Some(Err(_)) so callers decide whether to keep going. Async runtimes consume via spawn_blocking (see docs/pubsub.md in the workspace).

v1.3.0 makes embed work the same way as the network: two opens of the same mem://<name> or file:///path URL route through a process-local registry and share one backing Store + pub/sub bus. So the same code runs against mem:// in dev and kevy:// in prod with no scheme branching:

use kevy_client::{Connection, Subscriber, PubsubEvent};

let url = std::env::var("KEVY_URL").unwrap_or_else(|_| "mem://mailbus".into());
let mut sub = Subscriber::open(&url, &[b"news"])?;
let mut pubconn = Connection::open(&url)?;

// Drain the SUBSCRIBE ack first.
let _ack = sub.recv()?;

// Same URL → same bus, even across threads.
pubconn.publish(b"news", b"hello world")?;

if let PubsubEvent::Message { channel, payload } = sub.recv()? {
    println!("{}: {}", String::from_utf8_lossy(&channel),
                       String::from_utf8_lossy(&payload));
}
# Ok::<(), std::io::Error>(())

Anonymous mem:// (no name) stays per-call isolated — Subscriber::open rejects it with ErrorKind::Unsupported since no other producer can reach it. Use mem://<some-name> for a shared bus.

If you need a command this crate doesn't expose yet, drop down to the raw backend:

match &mut conn {
    kevy_client::Connection::Embedded(s) => { /* call any kevy_embedded::Store method */ }
    kevy_client::Connection::Remote(c)   => { /* call c.request(&[...]) directly */ }
}

Same code, two backends — test pattern

use kevy_client::Connection;

fn cache_smoke(c: &mut Connection) -> std::io::Result<()> {
    c.set(b"hot", b"cached")?;
    assert_eq!(c.get(b"hot")?, Some(b"cached".to_vec()));
    Ok(())
}

#[test]
fn smoke_embedded() -> std::io::Result<()> {
    cache_smoke(&mut Connection::open("mem://")?)
}

# // Run when a kevy server is up at $TEST_KEVY:
#[test]
#[ignore]   // gated on $TEST_KEVY env var pointing at a running server
fn smoke_remote() -> std::io::Result<()> {
    let url = std::env::var("TEST_KEVY").unwrap();
    cache_smoke(&mut Connection::open(&url)?)
}

License

MIT OR Apache-2.0, at your option.