Skip to main content

kevy_client/
lib.rs

1//! kevy-client — unified KV facade so downstream code can switch between
2//! in-process embedded and TCP-server backends with one URL string.
3//!
4//! ```no_run
5//! use kevy_client::Connection;
6//!
7//! // Same business code regardless of backend:
8//! let mut conn = Connection::connect(std::env::var("MY_KEVY_URL").unwrap().as_str())?;
9//! conn.set(b"hello", b"world")?;
10//! assert_eq!(conn.get(b"hello")?, Some(b"world".to_vec()));
11//! # Ok::<(), kevy_client::KevyError>(())
12//! ```
13//!
14//! URL schemes:
15//! - `mem://`                       — in-process embedded, in-memory only
16//! - `mem://<name>`                 — shared in-process bus keyed by `<name>`
17//! - `file:///abs/path` /
18//!   `file://./rel/path`            — in-process embedded with persistence
19//! - `kevy://host[:port][/db]`      — TCP RESP, kevy-native scheme
20//! - `redis://host[:port][/db]`     — TCP RESP, standard Redis URL (alias)
21//! - `tcp://host[:port]`            — TCP RESP, raw (no SELECT round-trip)
22//!
23//! Auth (`redis://user:pass@…`) and TLS (`rediss://`) are rejected up front
24//! — kevy ships without either. The crate covers the full string/hash/list/
25//! set/zset + one-shot `PUBLISH` surface. The pub/sub *consumer* side is a
26//! separate [`Subscriber`] type — a subscribed connection cannot send
27//! normal commands, so it needs its own socket and lives outside the
28//! `Connection` enum. `mem://<name>` / `file:///path` route through a
29//! process-local registry so the publisher and consumer can find each
30//! other when both opens use the same URL. The trait-vs-enum design
31//! decision is enum for now (closed two-backend universe); see ROADMAP
32//! for the trait extension path.
33//!
34//! Beyond that base the wrap surface tracks the full server op surface:
35//! blocking pops (`blpop`/`brpop`/`bzpopmin`), hash field-TTL
36//! (`hexpire`/`hpexpire`/`hpersist`/`httl`), zset algebra
37//! (`zinterstore`/`zunionstore`/`zintercard` + WEIGHTS/AGGREGATE),
38//! declarative indexes (`idx_*`), the CDC change feed (`feed_*`), and
39//! non-atomic [`Connection::pipeline`] batching. The full
40//! **op-family × wrap-status matrix** lives in this crate's README —
41//! anything listed there as raw-only is still reachable through
42//! [`Connection::pipeline`] / [`Transaction::queue`] argv passthrough.
43
44#![forbid(unsafe_code)]
45#![warn(missing_docs)]
46
47use std::time::Duration;
48
49use kevy_embedded::Store;
50use kevy_resp_client::RespClient;
51
52mod blocking;
53mod cluster;
54mod cluster_coll;
55mod collections;
56mod feed;
57mod hash_ttl;
58mod index;
59mod pipeline;
60mod reply;
61mod scan;
62mod subscribe;
63mod subscribe_io;
64mod transaction;
65mod url;
66mod zalgebra;
67
68pub use blocking::ZPopHit;
69pub use cluster::ClusterClient;
70pub use feed::{FeedBatch, FeedFrame};
71pub use index::{IdxInfo, IdxPage, IdxRow, IdxType};
72pub use pipeline::PipelineBuf;
73pub use subscribe::{PubsubEvent, Subscriber, SubscriberEvents, SubscriberMessages};
74pub use transaction::{Transaction, TransactionReplies};
75
76/// Re-exports so downstream code can name the argument/reply/error types
77/// of the wraps without adding kevy-embedded / kevy-resp deps.
78pub use kevy_embedded::{HExpireCode, HExpireCond, KevyError, KevyResult, StoreError, ZAggregate};
79pub use kevy_resp::Reply;
80
81pub(crate) use reply::{array_to_bulks, num_f64, num_u64, store_err, string, unexpected, vec2, vec3};
82pub(crate) use url::{Target, parse_url, resolve_store};
83
84/// One open connection to a kevy backend, opaque about whether the backend
85/// is in-process or over TCP.
86pub enum Connection {
87    /// In-process [`kevy_embedded::Store`]. Boxed because `Store` is
88    /// sizeable (carries its `Config`, including the replica
89    /// upstream/backoff fields) and dwarfs the `RespClient` variant.
90    Embedded(Box<Store>),
91    /// TCP [`kevy_resp_client::RespClient`].
92    Remote(RespClient),
93}
94
95impl Connection {
96    /// Connect to a backend chosen by URL scheme.
97    ///
98    /// See the crate-level docs for the supported URL forms. Two
99    /// `Connection::connect` calls with the same `mem://<name>` or
100    /// `file:///path` URL share the same backing `Store` — and the same
101    /// pub/sub bus, so `Connection::publish` reaches a
102    /// `Subscriber::connect_channels` opened with the same URL.
103    pub fn connect(url: &str) -> KevyResult<Self> {
104        let parsed = parse_url(url)?;
105        match parsed {
106            Target::Remote(remote_url) => Ok(Self::Remote(RespClient::connect_url(&remote_url)?)),
107            embed => Ok(Self::Embedded(Box::new(resolve_store(&embed)?))),
108        }
109    }
110
111    /// Remote-only feature gate: hand back the RESP client, or explain
112    /// why the embedded backend can't serve `feature` (`Unsupported`,
113    /// pointing at the `Connection::Embedded` escape hatch).
114    pub(crate) fn remote(&mut self, feature: &str) -> KevyResult<&mut RespClient> {
115        match self {
116            Self::Embedded(_) => Err(KevyError::Unsupported(format!(
117                "{feature} is remote-only; on the embedded backend match \
118                 Connection::Embedded and use kevy_embedded::Store's typed API"
119            ))),
120            Self::Remote(c) => Ok(c),
121        }
122    }
123
124    /// `PING`. Returns `()` on `+PONG`, propagates any IO or RESP error.
125    /// The first thing every healthcheck calls.
126    pub fn ping(&mut self) -> KevyResult<()> {
127        match self {
128            Self::Embedded(_) => Ok(()),
129            Self::Remote(c) => match c.request_borrowed(&[b"PING"])? {
130                Reply::Simple(s) if s == b"PONG" => Ok(()),
131                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
132                other => Err(unexpected(other)),
133            },
134        }
135    }
136
137    /// `SET key value`. Unconditional set (no NX/XX). Returns `()` on success.
138    pub fn set(&mut self, key: &[u8], value: &[u8]) -> KevyResult<()> {
139        match self {
140            Self::Embedded(s) => s.set(key, value).map(|_| ()),
141            Self::Remote(c) => match c.request_borrowed(&[b"SET", key, value])? {
142                Reply::Simple(s) if s == b"OK" => Ok(()),
143                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
144                other => Err(unexpected(other)),
145            },
146        }
147    }
148
149    /// `GET key`. `None` if absent or expired.
150    pub fn get(&mut self, key: &[u8]) -> KevyResult<Option<Vec<u8>>> {
151        match self {
152            Self::Embedded(s) => s.get(key),
153            Self::Remote(c) => match c.request_borrowed(&[b"GET", key])? {
154                Reply::Bulk(v) => Ok(Some(v)),
155                Reply::Nil => Ok(None),
156                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
157                other => Err(unexpected(other)),
158            },
159        }
160    }
161
162    /// `DEL key [key ...]`. Returns the count of keys that were actually
163    /// removed (existing + dropped). Missing keys don't contribute.
164    pub fn del(&mut self, keys: &[&[u8]]) -> KevyResult<usize> {
165        match self {
166            Self::Embedded(s) => s.del(keys),
167            Self::Remote(c) => {
168                let mut args: Vec<&[u8]> = Vec::with_capacity(keys.len() + 1);
169                args.push(b"DEL");
170                args.extend_from_slice(keys);
171                match c.request_borrowed(&args)? {
172                    Reply::Int(n) if n >= 0 => Ok(n as usize),
173                    Reply::Error(e) => Err(KevyError::Protocol(string(e))),
174                    other => Err(unexpected(other)),
175                }
176            }
177        }
178    }
179
180    /// `EXISTS key [key ...]`. Count of keys present (a single key can
181    /// contribute >1 if passed multiple times, matching Redis semantics).
182    pub fn exists(&mut self, keys: &[&[u8]]) -> KevyResult<usize> {
183        match self {
184            Self::Embedded(s) => s.exists(keys),
185            Self::Remote(c) => {
186                let mut args: Vec<&[u8]> = Vec::with_capacity(keys.len() + 1);
187                args.push(b"EXISTS");
188                args.extend_from_slice(keys);
189                match c.request_borrowed(&args)? {
190                    Reply::Int(n) if n >= 0 => Ok(n as usize),
191                    Reply::Error(e) => Err(KevyError::Protocol(string(e))),
192                    other => Err(unexpected(other)),
193                }
194            }
195        }
196    }
197
198    /// `INCR key`. Returns the post-increment value. Errors on non-integer
199    /// stored value.
200    pub fn incr(&mut self, key: &[u8]) -> KevyResult<i64> {
201        match self {
202            Self::Embedded(s) => s.incr(key),
203            Self::Remote(c) => match c.request_borrowed(&[b"INCR", key])? {
204                Reply::Int(n) => Ok(n),
205                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
206                other => Err(unexpected(other)),
207            },
208        }
209    }
210
211    /// `INCRBY key delta`. Negative delta is `DECRBY`. Returns post-value.
212    pub fn incr_by(&mut self, key: &[u8], delta: i64) -> KevyResult<i64> {
213        match self {
214            Self::Embedded(s) => s.incr_by(key, delta),
215            Self::Remote(c) => {
216                let delta_s = delta.to_string();
217                match c.request_borrowed(&[b"INCRBY", key, delta_s.as_bytes()])? {
218                    Reply::Int(n) => Ok(n),
219                    Reply::Error(e) => Err(KevyError::Protocol(string(e))),
220                    other => Err(unexpected(other)),
221                }
222            }
223        }
224    }
225
226    /// `PEXPIRE key ttl_ms`. Returns whether the key existed and got a TTL.
227    pub fn expire(&mut self, key: &[u8], ttl: Duration) -> KevyResult<bool> {
228        match self {
229            Self::Embedded(s) => s.expire(key, ttl),
230            Self::Remote(c) => {
231                let ms = ttl.as_millis().min(i64::MAX as u128) as i64;
232                let ms_s = ms.to_string();
233                match c.request_borrowed(&[b"PEXPIRE", key, ms_s.as_bytes()])? {
234                    Reply::Int(1) => Ok(true),
235                    Reply::Int(0) => Ok(false),
236                    Reply::Error(e) => Err(KevyError::Protocol(string(e))),
237                    other => Err(unexpected(other)),
238                }
239            }
240        }
241    }
242
243    /// `PERSIST key`. Returns whether a TTL was actually removed.
244    pub fn persist(&mut self, key: &[u8]) -> KevyResult<bool> {
245        match self {
246            Self::Embedded(s) => s.persist(key),
247            Self::Remote(c) => match c.request_borrowed(&[b"PERSIST", key])? {
248                Reply::Int(1) => Ok(true),
249                Reply::Int(0) => Ok(false),
250                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
251                other => Err(unexpected(other)),
252            },
253        }
254    }
255
256    /// `PTTL key`. Returns ms remaining, -2 if no key, -1 if key has no TTL.
257    pub fn ttl_ms(&mut self, key: &[u8]) -> KevyResult<i64> {
258        match self {
259            Self::Embedded(s) => Ok(s.ttl_ms(key)),
260            Self::Remote(c) => match c.request_borrowed(&[b"PTTL", key])? {
261                Reply::Int(n) => Ok(n),
262                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
263                other => Err(unexpected(other)),
264            },
265        }
266    }
267
268    /// `TYPE key`. Returns the value's type as a Redis-style string (e.g.
269    /// `"string"`, `"hash"`, `"list"`, `"set"`, `"zset"`, or `"none"` if
270    /// the key doesn't exist).
271    pub fn type_of(&mut self, key: &[u8]) -> KevyResult<String> {
272        match self {
273            Self::Embedded(s) => Ok(s.type_of(key).to_string()),
274            Self::Remote(c) => match c.request_borrowed(&[b"TYPE", key])? {
275                Reply::Simple(s) => Ok(string(s)),
276                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
277                other => Err(unexpected(other)),
278            },
279        }
280    }
281
282    /// `DBSIZE`. Total live keys at the time of the call.
283    pub fn dbsize(&mut self) -> KevyResult<usize> {
284        match self {
285            Self::Embedded(s) => Ok(s.dbsize()),
286            Self::Remote(c) => match c.request_borrowed(&[b"DBSIZE"])? {
287                Reply::Int(n) if n >= 0 => Ok(n as usize),
288                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
289                other => Err(unexpected(other)),
290            },
291        }
292    }
293
294    /// `FLUSHALL`. Drops every key. Persistence remains opted-in; embedded
295    /// `with_persist` will rewrite the AOF on its next sync cycle.
296    ///
297    /// Named `flushall` — **not** `flush` — to avoid colliding with
298    /// `Write::flush`'s "sync buffered writes to disk" meaning; this WIPES the
299    /// store rather than persisting it.
300    pub fn flushall(&mut self) -> KevyResult<()> {
301        match self {
302            Self::Embedded(s) => s.flushall(),
303            Self::Remote(c) => match c.request_borrowed(&[b"FLUSHALL"])? {
304                Reply::Simple(s) if s == b"OK" => Ok(()),
305                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
306                other => Err(unexpected(other)),
307            },
308        }
309    }
310
311    /// `SET key value PX ttl_ms`. Convenience for the common
312    /// "cache with expiry" pattern; equivalent to `set` + `expire` but
313    /// atomic.
314    pub fn set_with_ttl(&mut self, key: &[u8], value: &[u8], ttl: Duration) -> KevyResult<()> {
315        match self {
316            Self::Embedded(s) => s.set_with_ttl(key, value, ttl).map(|_| ()),
317            Self::Remote(c) => {
318                let ms = ttl.as_millis().min(i64::MAX as u128) as i64;
319                let ms_s = ms.to_string();
320                match c.request_borrowed(&[b"SET", key, value, b"PX", ms_s.as_bytes()])? {
321                    Reply::Simple(s) if s == b"OK" => Ok(()),
322                    Reply::Error(e) => Err(KevyError::Protocol(string(e))),
323                    other => Err(unexpected(other)),
324                }
325            }
326        }
327    }
328
329    /// `MGET key [key ...]` — one reply per key, `None` for missing /
330    /// wrong-type. Returns in the same order as `keys`.
331    pub fn mget(&mut self, keys: &[&[u8]]) -> KevyResult<Vec<Option<Vec<u8>>>> {
332        match self {
333            Self::Embedded(s) => keys.iter().map(|k| s.get(k)).collect(),
334            Self::Remote(c) => {
335                let mut args: Vec<&[u8]> = Vec::with_capacity(keys.len() + 1);
336                args.push(b"MGET");
337                args.extend_from_slice(keys);
338                match c.request_borrowed(&args)? {
339                    Reply::Array(items) => items
340                        .into_iter()
341                        .map(|r| match r {
342                            Reply::Bulk(v) => Ok(Some(v)),
343                            Reply::Nil => Ok(None),
344                            other => Err(unexpected(other)),
345                        })
346                        .collect(),
347                    Reply::Error(e) => Err(KevyError::Protocol(string(e))),
348                    other => Err(unexpected(other)),
349                }
350            }
351        }
352    }
353
354    /// `MSET key value [key value ...]` — set every pair atomically.
355    pub fn mset(&mut self, pairs: &[(&[u8], &[u8])]) -> KevyResult<()> {
356        match self {
357            Self::Embedded(s) => {
358                for (k, v) in pairs {
359                    s.set(k, v)?;
360                }
361                Ok(())
362            }
363            Self::Remote(c) => {
364                let mut args: Vec<&[u8]> = Vec::with_capacity(pairs.len() * 2 + 1);
365                args.push(b"MSET");
366                for &(k, v) in pairs {
367                    args.push(k);
368                    args.push(v);
369                }
370                match c.request_borrowed(&args)? {
371                    Reply::Simple(s) if s == b"OK" => Ok(()),
372                    Reply::Error(e) => Err(KevyError::Protocol(string(e))),
373                    other => Err(unexpected(other)),
374                }
375            }
376        }
377    }
378
379    /// `PUBLISH channel message`. Returns the count of subscribers
380    /// that received the message.
381    ///
382    /// The embedded backend has a real in-process pub/sub
383    /// bus: when a [`Subscriber`] is open against the same `mem://<name>`
384    /// or `file:///path` URL, this delivers there and returns the actual
385    /// receiver count. Anonymous `mem://` keeps the old "no subscribers,
386    /// returns 0" behaviour (the URL is its own bus, by design).
387    ///
388    /// The pub/sub *consumer* side lives in [`Subscriber`]. On the remote
389    /// backend a subscribed TCP connection cannot send normal commands
390    /// per the RESP spec; the embedded backend has no such restriction
391    /// but `Subscriber` is still a distinct type for API symmetry.
392    pub fn publish(&mut self, channel: &[u8], message: &[u8]) -> KevyResult<usize> {
393        match self {
394            Self::Embedded(s) => Ok(s.publish(channel, message)),
395            Self::Remote(c) => match c.request_borrowed(&[b"PUBLISH", channel, message])? {
396                Reply::Int(n) if n >= 0 => Ok(n as usize),
397                Reply::Error(e) => Err(KevyError::Protocol(string(e))),
398                other => Err(unexpected(other)),
399            },
400        }
401    }
402}
403
404
405#[cfg(test)]
406#[path = "lib_tests.rs"]
407mod tests;