Skip to main content

reddb_client/
lib.rs

1//! Official Rust client for [RedDB](https://github.com/reddb-io/reddb).
2//!
3//! One connection-string API. Pick your backend at runtime:
4//!
5//! ```no_run
6//! use reddb_client::{Reddb, JsonValue};
7//!
8//! # async fn run() -> reddb_client::Result<()> {
9//! // Embedded: opens the engine in-process, no network.
10//! let db = Reddb::connect("memory://").await?;
11//! db.insert("users", &JsonValue::object([("name", JsonValue::string("Alice"))])).await?;
12//! let result = db.query("SELECT * FROM users").await?;
13//! println!("{} rows", result.rows.len());
14//! db.close().await?;
15//! # Ok(())
16//! # }
17//! ```
18//!
19//! Accepted URIs:
20//!
21//! | URI                       | Backend                              | Status |
22//! |---------------------------|--------------------------------------|--------|
23//! | `memory://`               | Ephemeral in-memory                  | ✅    |
24//! | `file:///abs/path`        | Embedded engine on disk              | ✅    |
25//! | `grpc://host:port`        | Remote tonic client                  | ✅    |
26//! | `red://host:port`         | Remote tonic client (default port 5050) | ✅    |
27//! | `http://host:port`        | REST client                          | ✅    |
28//!
29//! ## Cargo features
30//!
31//! - `embedded` (default) — pulls the entire RedDB engine in-process.
32//! - `grpc` — opt-in remote client over tonic. Pulls the engine for
33//!   its `RedDBClient` type today; a thin proto-only client is tracked
34//!   in `PLAN_DRIVERS.md`.
35//! - `http` — REST client.
36//! - `redwire` — RedWire native TCP client (no engine dep).
37//!
38//! ## Internal connector
39//!
40//! The crate also hosts the gRPC connector + REPL used by the
41//! `red` and `red_client` binaries via the [`connector`] module.
42//! That layer is intentionally lighter than the published [`Reddb`]
43//! API: it speaks tonic + ureq + serde_json only and never pulls
44//! the engine in. It is exposed at the crate root as
45//! [`RedDBClient`] and [`repl`] for back-compat with the previous
46//! `reddb-client-internal` crate.
47
48#![allow(clippy::unwrap_used)]
49// Legacy allow for the cast_possible_truncation ratchet (PRD #1252):
50// pre-existing truncating `as` casts (e.g. f64→u64 narrowing). The lint bites
51// on new/changed code; remove once those casts become checked conversions.
52#![allow(clippy::cast_possible_truncation)]
53#![deny(unsafe_code)]
54#![warn(missing_debug_implementations)]
55
56pub mod bookmark_routing;
57pub mod connect;
58pub mod connector;
59pub mod error;
60pub mod params;
61pub mod routing_cache;
62pub mod topology;
63pub mod types;
64
65#[cfg(feature = "embedded")]
66pub mod embedded;
67
68#[cfg(feature = "grpc")]
69pub mod grpc;
70
71#[cfg(feature = "grpc")]
72pub mod router;
73
74pub mod redwire;
75pub mod row_format;
76
77#[cfg(feature = "http")]
78pub mod http;
79
80pub use error::{ClientError, ErrorCode, Result};
81pub use params::{IntoParams, IntoValue, Value};
82pub use row_format::{format_query_result, RowFormat};
83pub use types::{
84    BulkInsertResult, DeleteResult, DocumentItem, ExistsResult, InsertResult, JsonValue, KvItem,
85    KvWatchEvent, ListOptions, ListResult, QueryResult, Row, ValueOut,
86};
87
88// Back-compat re-exports for the previous `reddb-client-internal`
89// crate. Workspace consumers (`reddb-server::rpc_stdio`, the `red`
90// bin's REPL launcher, the `red_client` bin) import these paths
91// directly.
92pub use bookmark_routing::{CausalReadOptions, QueryOptions, ReadConsistency};
93pub use connector::{
94    repl, BulkCreateStatus, CreatedEntity, HealthStatus, OperationStatus, QueryResponse,
95    RedDBClient,
96};
97
98use connect::Target;
99
100/// Top-level client handle. Use [`Reddb::connect`] to get one.
101#[derive(Debug)]
102pub enum Reddb {
103    #[cfg(feature = "embedded")]
104    Embedded(embedded::EmbeddedClient),
105    #[cfg(feature = "grpc")]
106    Grpc(grpc::GrpcClient),
107    #[cfg(feature = "http")]
108    Http(http::HttpClient),
109    /// Constructed when a feature gate would have produced a real
110    /// variant but the feature is disabled. Every method on this
111    /// variant returns a `FEATURE_DISABLED` error so build-time
112    /// configuration bugs surface as runtime errors with a clear
113    /// remediation, not as missing trait impls.
114    Unavailable(&'static str),
115}
116
117impl Reddb {
118    /// Open a connection. The backend is selected from the URI scheme.
119    pub async fn connect(uri: &str) -> Result<Self> {
120        let target = connect::parse(uri)?;
121        match target {
122            Target::Memory => {
123                #[cfg(feature = "embedded")]
124                {
125                    embedded::EmbeddedClient::in_memory().map(Reddb::Embedded)
126                }
127                #[cfg(not(feature = "embedded"))]
128                {
129                    Err(ClientError::feature_disabled("embedded"))
130                }
131            }
132            Target::File { path } => {
133                #[cfg(feature = "embedded")]
134                {
135                    embedded::EmbeddedClient::open(path).map(Reddb::Embedded)
136                }
137                #[cfg(not(feature = "embedded"))]
138                {
139                    let _ = path;
140                    Err(ClientError::feature_disabled("embedded"))
141                }
142            }
143            Target::Grpc { endpoint } => {
144                #[cfg(feature = "grpc")]
145                {
146                    grpc::GrpcClient::connect(endpoint).await.map(Reddb::Grpc)
147                }
148                #[cfg(not(feature = "grpc"))]
149                {
150                    let _ = endpoint;
151                    Err(ClientError::feature_disabled("grpc"))
152                }
153            }
154            Target::GrpcCluster {
155                primary,
156                replicas,
157                force_primary,
158            } => {
159                #[cfg(feature = "grpc")]
160                {
161                    grpc::GrpcClient::connect_cluster(primary, replicas, force_primary)
162                        .await
163                        .map(Reddb::Grpc)
164                }
165                #[cfg(not(feature = "grpc"))]
166                {
167                    let _ = (primary, replicas, force_primary);
168                    Err(ClientError::feature_disabled("grpc"))
169                }
170            }
171            Target::Http { base_url } => {
172                #[cfg(feature = "http")]
173                {
174                    http::HttpClient::connect(http::HttpOptions::new(base_url))
175                        .await
176                        .map(Reddb::Http)
177                }
178                #[cfg(not(feature = "http"))]
179                {
180                    let _ = base_url;
181                    Err(ClientError::feature_disabled("http"))
182                }
183            }
184        }
185    }
186
187    pub async fn query(&self, sql: &str) -> Result<QueryResult> {
188        // Spec §3.1: empty SQL is a caller bug; reject locally before the
189        // request is sent so every transport (embedded, gRPC, HTTP) maps it to
190        // the same `INVALID_ARGUMENT` code.
191        if sql.trim().is_empty() {
192            return Err(ClientError::new(
193                ErrorCode::InvalidArgument,
194                "query SQL must not be empty",
195            ));
196        }
197        match self {
198            #[cfg(feature = "embedded")]
199            Reddb::Embedded(c) => c.query(sql),
200            #[cfg(feature = "grpc")]
201            Reddb::Grpc(c) => c.query(sql).await,
202            #[cfg(feature = "http")]
203            Reddb::Http(c) => c.query(sql).await,
204            Reddb::Unavailable(name) => Err(ClientError::feature_disabled(name)),
205        }
206    }
207
208    pub async fn query_with_options(
209        &self,
210        sql: &str,
211        options: QueryOptions,
212    ) -> Result<QueryResult> {
213        if sql.trim().is_empty() {
214            return Err(ClientError::new(
215                ErrorCode::InvalidArgument,
216                "query SQL must not be empty",
217            ));
218        }
219        match self {
220            #[cfg(feature = "embedded")]
221            Reddb::Embedded(c) => {
222                let _ = options;
223                c.query(sql)
224            }
225            #[cfg(feature = "grpc")]
226            Reddb::Grpc(c) => c.query_with_options(sql, options).await,
227            #[cfg(feature = "http")]
228            Reddb::Http(c) => {
229                let _ = options;
230                c.query(sql).await
231            }
232            Reddb::Unavailable(name) => Err(ClientError::feature_disabled(name)),
233        }
234    }
235
236    /// Parameterized query — `$N` placeholders in `sql` are bound to
237    /// `params[N-1]`. Empty params is equivalent to [`Self::query`].
238    ///
239    /// Native type mapping (driver-side, [`IntoValue`]):
240    ///
241    /// | Rust                    | Engine `Value` variant |
242    /// |-------------------------|------------------------|
243    /// | `i8..i64` / `u8..u32`   | `Integer` (i64)        |
244    /// | `bool`                  | `Boolean`              |
245    /// | `f32` / `f64`           | `Float` (f64)          |
246    /// | `&str` / `String`       | `Text`                 |
247    /// | `Vec<u8>` / `&[u8]`     | `Blob`                 |
248    /// | `Vec<f32>` / `&[f32]`   | `Vector`               |
249    /// | `Option<T>`             | `Null` when `None`     |
250    /// | `serde_json::Value`     | `Json`                 |
251    /// | [`Value::Timestamp`]    | `Timestamp` (seconds)  |
252    /// | [`Value::Uuid`]         | `Uuid` (16 raw bytes)  |
253    ///
254    /// Today the [`Reddb::Embedded`], [`Reddb::Grpc`], and [`Reddb::Http`]
255    /// transports carry parameters end-to-end.
256    pub async fn query_with<P: IntoParams>(&self, sql: &str, params: P) -> Result<QueryResult> {
257        let values = params.into_params();
258        match self {
259            #[cfg(feature = "embedded")]
260            Reddb::Embedded(c) => c.query_with(sql, values),
261            #[cfg(feature = "grpc")]
262            Reddb::Grpc(c) => c.query_with(sql, &values).await,
263            #[cfg(feature = "http")]
264            Reddb::Http(c) => c.query_with(sql, &values).await,
265            Reddb::Unavailable(name) => Err(ClientError::feature_disabled(name)),
266        }
267    }
268
269    /// Parameterized execution for DML statements. This is an alias for
270    /// [`Self::query_with`] because RedDB returns one query result envelope for
271    /// SELECT and DML.
272    pub async fn execute_with<P: IntoParams>(&self, sql: &str, params: P) -> Result<QueryResult> {
273        self.query_with(sql, params).await
274    }
275
276    pub async fn insert(&self, collection: &str, payload: &JsonValue) -> Result<InsertResult> {
277        match self {
278            #[cfg(feature = "embedded")]
279            Reddb::Embedded(c) => c.insert(collection, payload),
280            #[cfg(feature = "grpc")]
281            Reddb::Grpc(c) => c.insert(collection, payload).await,
282            #[cfg(feature = "http")]
283            Reddb::Http(c) => c.insert(collection, payload).await,
284            Reddb::Unavailable(name) => Err(ClientError::feature_disabled(name)),
285        }
286    }
287
288    pub async fn bulk_insert(
289        &self,
290        collection: &str,
291        payloads: &[JsonValue],
292    ) -> Result<BulkInsertResult> {
293        match self {
294            #[cfg(feature = "embedded")]
295            Reddb::Embedded(c) => c.bulk_insert(collection, payloads),
296            #[cfg(feature = "grpc")]
297            Reddb::Grpc(c) => c.bulk_insert(collection, payloads).await,
298            #[cfg(feature = "http")]
299            Reddb::Http(c) => c.bulk_insert(collection, payloads).await,
300            Reddb::Unavailable(name) => Err(ClientError::feature_disabled(name)),
301        }
302    }
303
304    pub async fn delete(&self, collection: &str, rid: &str) -> Result<u64> {
305        match self {
306            #[cfg(feature = "embedded")]
307            Reddb::Embedded(c) => c.delete(collection, rid),
308            #[cfg(feature = "grpc")]
309            Reddb::Grpc(c) => c.delete(collection, rid).await,
310            #[cfg(feature = "http")]
311            Reddb::Http(c) => c.delete(collection, rid).await,
312            Reddb::Unavailable(name) => Err(ClientError::feature_disabled(name)),
313        }
314    }
315
316    pub fn documents(&self) -> DocumentClient<'_> {
317        DocumentClient { db: self }
318    }
319
320    pub fn queue(&self) -> QueueClient<'_> {
321        QueueClient { db: self }
322    }
323
324    pub fn kv_collection<'a>(&'a self, collection: &'a str) -> KvClient<'a> {
325        KvClient {
326            db: self,
327            collection,
328        }
329    }
330
331    pub async fn begin(&self) -> Result<QueryResult> {
332        self.query("BEGIN").await
333    }
334
335    pub async fn commit(&self) -> Result<QueryResult> {
336        self.query("COMMIT").await
337    }
338
339    pub async fn rollback(&self) -> Result<QueryResult> {
340        self.query("ROLLBACK").await
341    }
342
343    pub async fn close(&self) -> Result<()> {
344        match self {
345            #[cfg(feature = "embedded")]
346            Reddb::Embedded(c) => c.close(),
347            #[cfg(feature = "grpc")]
348            Reddb::Grpc(c) => c.close().await,
349            #[cfg(feature = "http")]
350            Reddb::Http(c) => c.close().await,
351            Reddb::Unavailable(_) => Ok(()),
352        }
353    }
354
355    pub fn kv(&self) -> KvClient<'_> {
356        KvClient {
357            db: self,
358            collection: "kv_default",
359        }
360    }
361
362    pub fn config(&self) -> ConfigClient<'_> {
363        self.config_collection("red.config")
364    }
365
366    pub fn vault(&self) -> VaultClient<'_> {
367        self.vault_collection("red.vault")
368    }
369
370    pub fn config_collection<'a>(&'a self, collection: &'a str) -> ConfigClient<'a> {
371        ConfigClient {
372            db: self,
373            collection,
374        }
375    }
376
377    pub fn vault_collection<'a>(&'a self, collection: &'a str) -> VaultClient<'a> {
378        VaultClient {
379            db: self,
380            collection,
381        }
382    }
383}
384
385#[derive(Debug)]
386pub struct DocumentClient<'a> {
387    db: &'a Reddb,
388}
389
390impl<'a> DocumentClient<'a> {
391    pub async fn insert(&self, collection: &str, body: &JsonValue) -> Result<DocumentItem> {
392        ensure_json_object("document body", body)?;
393        let collection = sql_identifier(collection)?;
394        self.db
395            .query(&format!("CREATE DOCUMENT IF NOT EXISTS {collection}"))
396            .await?;
397        let result = self
398            .db
399            .query(&format!(
400                "INSERT INTO {collection} DOCUMENT VALUES ({}) RETURNING *",
401                json_inline_literal(body)
402            ))
403            .await?;
404        document_item_from_first_row(result)
405    }
406
407    pub async fn get(&self, collection: &str, rid: &str) -> Result<DocumentItem> {
408        let collection = sql_identifier(collection)?;
409        let result = self
410            .db
411            .query(&format!(
412                "SELECT * FROM {collection} WHERE rid = {} LIMIT 1",
413                sql_string_literal(rid)
414            ))
415            .await?;
416        document_item_from_first_row(result)
417    }
418
419    pub async fn list(&self, collection: &str, options: ListOptions<'_>) -> Result<ListResult> {
420        let collection = sql_identifier(collection)?;
421        let result = self
422            .db
423            .query(&select_sql(&collection, "*", &options))
424            .await?;
425        Ok(ListResult {
426            affected: result.affected,
427            items: result.rows,
428        })
429    }
430
431    pub async fn filter(&self, collection: &str, filter: &str) -> Result<ListResult> {
432        self.list(collection, ListOptions::new().filter(filter))
433            .await
434    }
435
436    pub async fn patch(
437        &self,
438        collection: &str,
439        rid: &str,
440        patch: &JsonValue,
441    ) -> Result<DocumentItem> {
442        let entries = patch.as_object().ok_or_else(|| {
443            ClientError::new(
444                ErrorCode::InvalidArgument,
445                "document patch must be a JSON object",
446            )
447        })?;
448        if entries.is_empty() {
449            return Err(ClientError::new(
450                ErrorCode::InvalidArgument,
451                "document patch must contain at least one field",
452            ));
453        }
454        let collection = sql_identifier(collection)?;
455        let assignments = entries
456            .iter()
457            .map(|(field, value)| {
458                Ok(format!(
459                    "{} = {}",
460                    sql_identifier(field)?,
461                    json_value_literal(value)
462                ))
463            })
464            .collect::<Result<Vec<_>>>()?;
465        let result = self
466            .db
467            .query(&format!(
468                "UPDATE {collection} SET {} WHERE rid = {} RETURNING *",
469                assignments.join(", "),
470                sql_string_literal(rid)
471            ))
472            .await?;
473        document_item_from_first_row(result)
474    }
475
476    pub async fn delete(&self, collection: &str, rid: &str) -> Result<DeleteResult> {
477        let collection = sql_identifier(collection)?;
478        let result = self
479            .db
480            .query(&format!(
481                "DELETE FROM {collection} WHERE rid = {}",
482                sql_string_literal(rid)
483            ))
484            .await?;
485        Ok(DeleteResult {
486            affected: result.affected,
487            deleted: result.affected > 0,
488        })
489    }
490}
491
492#[derive(Debug)]
493pub struct QueueClient<'a> {
494    db: &'a Reddb,
495}
496
497#[derive(Debug, Clone, Default)]
498pub struct QueuePushOptions {
499    pub priority: Option<i32>,
500    pub key: Option<String>,
501    pub dedup: Option<String>,
502    pub delay: Option<String>,
503    pub at: Option<u64>,
504}
505
506impl QueuePushOptions {
507    pub fn new() -> Self {
508        Self::default()
509    }
510
511    pub fn priority(mut self, priority: i32) -> Self {
512        self.priority = Some(priority);
513        self
514    }
515
516    pub fn key(mut self, key: impl Into<String>) -> Self {
517        self.key = Some(key.into());
518        self
519    }
520
521    pub fn dedup(mut self, dedup: impl Into<String>) -> Self {
522        self.dedup = Some(dedup.into());
523        self
524    }
525
526    pub fn delay(mut self, delay: impl Into<String>) -> Self {
527        self.delay = Some(delay.into());
528        self
529    }
530
531    pub fn at(mut self, at: u64) -> Self {
532        self.at = Some(at);
533        self
534    }
535}
536
537impl<'a> QueueClient<'a> {
538    pub async fn create(&self, queue: &str) -> Result<QueryResult> {
539        self.db
540            .query(&format!(
541                "CREATE QUEUE IF NOT EXISTS {}",
542                sql_identifier(queue)?
543            ))
544            .await
545    }
546
547    pub async fn push(
548        &self,
549        queue: &str,
550        value: &JsonValue,
551        options: QueuePushOptions,
552    ) -> Result<QueryResult> {
553        if options.key.is_some() && (options.delay.is_some() || options.at.is_some()) {
554            return Err(ClientError::new(
555                ErrorCode::InvalidArgument,
556                "QUEUE PUSH KEY cannot be combined with DELAY / AVAILABLE AT",
557            ));
558        }
559        self.db
560            .query(&format!(
561                "QUEUE PUSH {} {}{}",
562                sql_identifier(queue)?,
563                json_value_literal(value),
564                queue_push_options_sql(&options)
565            ))
566            .await
567    }
568
569    pub async fn peek(&self, queue: &str, limit: Option<u64>) -> Result<ListResult> {
570        let mut sql = format!("QUEUE PEEK {}", sql_identifier(queue)?);
571        if let Some(limit) = limit {
572            sql.push_str(&format!(" {limit}"));
573        }
574        let result = self.db.query(&sql).await?;
575        Ok(ListResult {
576            affected: result.affected,
577            items: result.rows,
578        })
579    }
580
581    pub async fn pop(&self, queue: &str) -> Result<ListResult> {
582        let result = self
583            .db
584            .query(&format!("QUEUE POP {}", sql_identifier(queue)?))
585            .await?;
586        Ok(ListResult {
587            affected: result.affected,
588            items: result.rows,
589        })
590    }
591
592    pub async fn len(&self, queue: &str) -> Result<u64> {
593        let result = self
594            .db
595            .query(&format!("QUEUE LEN {}", sql_identifier(queue)?))
596            .await?;
597        row_value(&result.rows, "len")
598            .and_then(value_as_u64)
599            .ok_or_else(|| ClientError::new(ErrorCode::InvalidResponse, "QUEUE LEN missing len"))
600    }
601
602    pub async fn purge(&self, queue: &str) -> Result<DeleteResult> {
603        let result = self
604            .db
605            .query(&format!("QUEUE PURGE {}", sql_identifier(queue)?))
606            .await?;
607        Ok(DeleteResult {
608            affected: result.affected,
609            deleted: result.affected > 0,
610        })
611    }
612
613    /// `QUEUE READ … WAIT <wait>` — block until a message is available
614    /// for `consumer` on `queue` (optionally scoped to `group`), the
615    /// `wait` budget elapses, or the server cancels.
616    ///
617    /// Timeout returns an empty `ListResult` — the same shape an empty
618    /// non-waiting read would produce — never an error. Cancellation
619    /// and cap rejection surface as `ClientError`s from the underlying
620    /// transport (PRD #718 slice C). `wait` must be specified — there
621    /// is no infinite-wait default.
622    pub async fn read_wait(
623        &self,
624        queue: &str,
625        consumer: &str,
626        wait: std::time::Duration,
627        opts: QueueReadWaitOptions<'_>,
628    ) -> Result<ListResult> {
629        let queue = sql_identifier(queue)?;
630        let consumer = sql_identifier(consumer)?;
631        let group = match opts.group {
632            Some(g) => format!(" GROUP {}", sql_identifier(g)?),
633            None => String::new(),
634        };
635        let count = match opts.count {
636            Some(c) => format!(" COUNT {c}"),
637            None => String::new(),
638        };
639        let wait_ms = wait.as_millis() as u64;
640        let sql = format!("QUEUE READ {queue}{group} CONSUMER {consumer}{count} WAIT {wait_ms}ms");
641        let result = self.db.query(&sql).await?;
642        Ok(ListResult {
643            affected: result.affected,
644            items: result.rows,
645        })
646    }
647}
648
649/// Optional knobs for [`QueueClient::read_wait`].
650#[derive(Debug, Default, Clone, Copy)]
651pub struct QueueReadWaitOptions<'a> {
652    /// Consumer group. When `None`, the server uses the queue's
653    /// default fan-out group.
654    pub group: Option<&'a str>,
655    /// Maximum number of messages to deliver. Defaults to 1 server-side.
656    pub count: Option<u32>,
657}
658
659#[derive(Debug)]
660pub struct KvClient<'a> {
661    db: &'a Reddb,
662    collection: &'a str,
663}
664
665impl<'a> KvClient<'a> {
666    pub async fn set(&self, key: &str, value: JsonValue) -> Result<QueryResult> {
667        self.put(key, value, &[]).await
668    }
669
670    pub async fn put(&self, key: &str, value: JsonValue, tags: &[&str]) -> Result<QueryResult> {
671        let tag_clause = if tags.is_empty() {
672            String::new()
673        } else {
674            format!(
675                " TAGS [{}]",
676                tags.iter()
677                    .map(|tag| kv_tag_literal(tag))
678                    .collect::<Vec<_>>()
679                    .join(", ")
680            )
681        };
682        self.db
683            .query(&format!(
684                "KV PUT {}.{} = {}{}",
685                kv_collection_identifier(self.collection)?,
686                kv_path_segment(key),
687                json_value_literal(&value),
688                tag_clause
689            ))
690            .await
691    }
692
693    pub async fn get(&self, key: &str) -> Result<Option<KvItem>> {
694        let result = self
695            .db
696            .query(&format!(
697                "KV GET {}.{}",
698                kv_collection_identifier(self.collection)?,
699                kv_path_segment(key)
700            ))
701            .await?;
702        Ok(kv_item_from_rows(&result.rows))
703    }
704
705    pub async fn exists(&self, key: &str) -> Result<ExistsResult> {
706        Ok(ExistsResult {
707            exists: self.get(key).await?.is_some(),
708        })
709    }
710
711    pub async fn delete(&self, key: &str) -> Result<DeleteResult> {
712        let result = self
713            .db
714            .query(&format!(
715                "KV DELETE {}.{}",
716                kv_collection_identifier(self.collection)?,
717                kv_path_segment(key)
718            ))
719            .await?;
720        Ok(DeleteResult {
721            affected: result.affected,
722            deleted: result.affected > 0,
723        })
724    }
725
726    pub async fn list(&self, options: ListOptions<'_>) -> Result<ListResult> {
727        let collection = sql_identifier(self.collection)?;
728        let result = self
729            .db
730            .query(&select_sql(&collection, "key, value", &options))
731            .await?;
732        Ok(ListResult {
733            affected: result.affected,
734            items: result.rows,
735        })
736    }
737
738    pub async fn invalidate_tags(&self, tags: &[&str]) -> Result<u64> {
739        let result = self
740            .db
741            .query(&format!(
742                "INVALIDATE TAGS [{}] FROM {}",
743                tags.iter()
744                    .map(|tag| kv_tag_literal(tag))
745                    .collect::<Vec<_>>()
746                    .join(", "),
747                kv_collection_identifier(self.collection)?
748            ))
749            .await?;
750        Ok(result.affected)
751    }
752
753    pub async fn watch(&self, key: &str) -> Result<Vec<KvWatchEvent>> {
754        self.watch_from_lsn(key, None).await
755    }
756
757    pub async fn watch_from_lsn(
758        &self,
759        key: &str,
760        from_lsn: Option<u64>,
761    ) -> Result<Vec<KvWatchEvent>> {
762        #[cfg(not(feature = "http"))]
763        {
764            let _ = key;
765            let _ = from_lsn;
766            let _ = self.collection;
767        }
768        match self.db {
769            #[cfg(feature = "http")]
770            Reddb::Http(c) => c.watch_kv(self.collection, key, from_lsn, None).await,
771            #[cfg(feature = "embedded")]
772            Reddb::Embedded(_) => Err(ClientError::feature_disabled("kv.watch embedded")),
773            #[cfg(feature = "grpc")]
774            Reddb::Grpc(_) => Err(ClientError::feature_disabled("kv.watch grpc")),
775            Reddb::Unavailable(name) => Err(ClientError::feature_disabled(name)),
776        }
777    }
778
779    pub async fn watch_prefix(&self, prefix: &str) -> Result<Vec<KvWatchEvent>> {
780        self.watch_prefix_from_lsn(prefix, None).await
781    }
782
783    pub async fn watch_prefix_from_lsn(
784        &self,
785        prefix: &str,
786        from_lsn: Option<u64>,
787    ) -> Result<Vec<KvWatchEvent>> {
788        let key = format!("{prefix}.*");
789        self.watch_from_lsn(&key, from_lsn).await
790    }
791}
792
793#[derive(Debug)]
794pub struct ConfigClient<'a> {
795    db: &'a Reddb,
796    collection: &'a str,
797}
798
799impl<'a> ConfigClient<'a> {
800    pub async fn put(&self, key: &str, value: JsonValue, tags: &[&str]) -> Result<QueryResult> {
801        let mut sql = format!(
802            "PUT CONFIG {} {} = {}",
803            kv_collection_identifier(self.collection)?,
804            kv_path_segment(key),
805            json_value_literal(&value)
806        );
807        append_tag_clause(&mut sql, tags);
808        self.db.query(&sql).await
809    }
810
811    pub async fn put_secret_ref(
812        &self,
813        key: &str,
814        vault_collection: &str,
815        vault_key: &str,
816        tags: &[&str],
817    ) -> Result<QueryResult> {
818        let mut sql = format!(
819            "PUT CONFIG {} {} = SECRET_REF(vault, {}.{})",
820            kv_collection_identifier(self.collection)?,
821            kv_path_segment(key),
822            kv_collection_identifier(vault_collection)?,
823            kv_path_segment(vault_key)
824        );
825        append_tag_clause(&mut sql, tags);
826        self.db.query(&sql).await
827    }
828
829    pub async fn get(&self, key: &str) -> Result<QueryResult> {
830        self.db
831            .query(&format!(
832                "GET CONFIG {} {}",
833                kv_collection_identifier(self.collection)?,
834                kv_path_segment(key)
835            ))
836            .await
837    }
838
839    pub async fn resolve(&self, key: &str) -> Result<QueryResult> {
840        self.db
841            .query(&format!(
842                "RESOLVE CONFIG {} {}",
843                kv_collection_identifier(self.collection)?,
844                kv_path_segment(key)
845            ))
846            .await
847    }
848}
849
850#[derive(Debug)]
851pub struct VaultClient<'a> {
852    db: &'a Reddb,
853    collection: &'a str,
854}
855
856impl<'a> VaultClient<'a> {
857    pub async fn put(&self, key: &str, value: JsonValue, tags: &[&str]) -> Result<QueryResult> {
858        let mut sql = format!(
859            "VAULT PUT {}.{} = {}",
860            kv_collection_identifier(self.collection)?,
861            kv_path_segment(key),
862            json_value_literal(&value)
863        );
864        append_tag_clause(&mut sql, tags);
865        self.db.query(&sql).await
866    }
867
868    pub async fn get(&self, key: &str) -> Result<QueryResult> {
869        self.db
870            .query(&format!(
871                "VAULT GET {}.{}",
872                kv_collection_identifier(self.collection)?,
873                kv_path_segment(key)
874            ))
875            .await
876    }
877
878    pub async fn unseal(&self, key: &str) -> Result<QueryResult> {
879        self.db
880            .query(&format!(
881                "UNSEAL VAULT {}.{}",
882                kv_collection_identifier(self.collection)?,
883                kv_path_segment(key)
884            ))
885            .await
886    }
887}
888
889fn append_tag_clause(sql: &mut String, tags: &[&str]) {
890    if tags.is_empty() {
891        return;
892    }
893    sql.push_str(" TAGS [");
894    sql.push_str(
895        &tags
896            .iter()
897            .map(|tag| kv_tag_literal(tag))
898            .collect::<Vec<_>>()
899            .join(", "),
900    );
901    sql.push(']');
902}
903
904fn sql_identifier(value: &str) -> Result<String> {
905    let mut chars = value.chars();
906    let Some(first) = chars.next() else {
907        return Err(ClientError::new(
908            ErrorCode::InvalidArgument,
909            "identifier must not be empty",
910        ));
911    };
912    if !(first.is_ascii_alphabetic() || first == '_') {
913        return Err(ClientError::new(
914            ErrorCode::InvalidArgument,
915            format!("invalid identifier `{value}`"),
916        ));
917    }
918    if chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
919        Ok(value.to_string())
920    } else {
921        Err(ClientError::new(
922            ErrorCode::InvalidArgument,
923            format!("invalid identifier `{value}`"),
924        ))
925    }
926}
927
928fn kv_collection_identifier(value: &str) -> Result<String> {
929    let mut parts = Vec::new();
930    for part in value.split('.') {
931        parts.push(sql_identifier(part)?);
932    }
933    Ok(parts.join("."))
934}
935
936fn kv_path_segment(value: &str) -> String {
937    if is_plain_path_segment(value) {
938        value.to_string()
939    } else {
940        sql_string_literal(value)
941    }
942}
943
944fn is_plain_path_segment(value: &str) -> bool {
945    let mut chars = value.chars();
946    let Some(first) = chars.next() else {
947        return false;
948    };
949    (first.is_ascii_alphabetic() || first == '_')
950        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
951}
952
953fn json_value_literal(value: &JsonValue) -> String {
954    match value {
955        JsonValue::Null => "NULL".to_string(),
956        JsonValue::Bool(value) => value.to_string(),
957        JsonValue::Number(value) => value.to_string(),
958        JsonValue::String(value) => sql_string_literal(value),
959        JsonValue::Array(_) | JsonValue::Object(_) => value.to_json_string(),
960    }
961}
962
963fn queue_push_options_sql(options: &QueuePushOptions) -> String {
964    let mut sql = String::new();
965    if let Some(priority) = options.priority {
966        sql.push_str(&format!(" PRIORITY {priority}"));
967    }
968    if let Some(key) = &options.key {
969        sql.push_str(&format!(" KEY {}", sql_string_literal(key)));
970    }
971    if let Some(dedup) = &options.dedup {
972        sql.push_str(&format!(" DEDUP {}", sql_string_literal(dedup)));
973    }
974    if let Some(delay) = &options.delay {
975        sql.push_str(&format!(" DELAY {delay}"));
976    }
977    if let Some(at) = options.at {
978        sql.push_str(&format!(" AVAILABLE AT {at}"));
979    }
980    sql
981}
982
983/// ADR 0067 (#1709): a document body is emitted as an inline strict-JSON
984/// literal (`DOCUMENT VALUES ({...})`), not the removed quoted-string
985/// coercion. Callers guarantee `value` is a JSON object.
986fn json_inline_literal(value: &JsonValue) -> String {
987    value.to_json_string()
988}
989
990fn kv_tag_literal(value: &str) -> String {
991    sql_string_literal(value)
992}
993
994fn sql_string_literal(value: &str) -> String {
995    format!("'{}'", value.replace('\'', "''"))
996}
997
998fn ensure_json_object(name: &str, value: &JsonValue) -> Result<()> {
999    if value.as_object().is_some() {
1000        Ok(())
1001    } else {
1002        Err(ClientError::new(
1003            ErrorCode::InvalidArgument,
1004            format!("{name} must be a JSON object"),
1005        ))
1006    }
1007}
1008
1009fn select_sql(collection: &str, columns: &str, options: &ListOptions<'_>) -> String {
1010    let mut sql = format!("SELECT {columns} FROM {collection}");
1011    if let Some(filter) = options.filter {
1012        sql.push_str(" WHERE ");
1013        sql.push_str(filter);
1014    }
1015    if let Some(order_by) = options.order_by {
1016        sql.push_str(" ORDER BY ");
1017        sql.push_str(order_by);
1018    }
1019    if let Some(limit) = options.limit {
1020        sql.push_str(&format!(" LIMIT {limit}"));
1021    }
1022    sql
1023}
1024
1025fn document_item_from_first_row(result: QueryResult) -> Result<DocumentItem> {
1026    let Some(row) = result.rows.into_iter().next() else {
1027        return Err(ClientError::new(ErrorCode::NotFound, "document not found"));
1028    };
1029    let rid = row
1030        .iter()
1031        .find(|(column, _)| column == "rid")
1032        .and_then(|(_, value)| value_as_string(value))
1033        .ok_or_else(|| ClientError::new(ErrorCode::InvalidResponse, "document row missing rid"))?;
1034    Ok(DocumentItem { rid, fields: row })
1035}
1036
1037fn kv_item_from_rows(rows: &[Row]) -> Option<KvItem> {
1038    let row = rows.first()?;
1039    let value = row
1040        .iter()
1041        .find(|(column, _)| column == "value")
1042        .map(|(_, value)| value.clone())?;
1043    let rid = row
1044        .iter()
1045        .find(|(column, _)| column == "rid")
1046        .map(|(_, value)| value);
1047    if matches!(rid, Some(ValueOut::Null)) && value == ValueOut::Null {
1048        return None;
1049    }
1050    let collection = row
1051        .iter()
1052        .find(|(column, _)| column == "collection")
1053        .and_then(|(_, value)| value_as_string(value))
1054        .unwrap_or_default();
1055    let key = row
1056        .iter()
1057        .find(|(column, _)| column == "key")
1058        .and_then(|(_, value)| value_as_string(value))
1059        .unwrap_or_default();
1060    Some(KvItem {
1061        collection,
1062        key,
1063        value,
1064    })
1065}
1066
1067fn row_value<'a>(rows: &'a [Row], column: &str) -> Option<&'a ValueOut> {
1068    rows.first()?
1069        .iter()
1070        .find(|(name, _)| name == column)
1071        .map(|(_, value)| value)
1072}
1073
1074fn value_as_string(value: &ValueOut) -> Option<String> {
1075    match value {
1076        ValueOut::String(value) => Some(value.clone()),
1077        ValueOut::Integer(value) => Some(value.to_string()),
1078        _ => None,
1079    }
1080}
1081
1082fn value_as_u64(value: &ValueOut) -> Option<u64> {
1083    match value {
1084        ValueOut::Integer(value) => (*value).try_into().ok(),
1085        ValueOut::Float(value) if *value >= 0.0 => Some(*value as u64),
1086        ValueOut::String(value) => value.parse().ok(),
1087        _ => None,
1088    }
1089}
1090
1091/// Crate version (matches the engine version when published in lockstep).
1092pub fn version() -> &'static str {
1093    env!("CARGO_PKG_VERSION")
1094}
1095
1096/// SDK Helper Spec version this driver conforms to.
1097///
1098/// Spec §14 asks every official driver to expose a `helper_spec_version`
1099/// constant so cross-driver CI dashboards can assert all drivers track the
1100/// same contract. Rust is the reference implementation: `reddb-io-client`
1101/// ≥ 1.2.0 satisfies Helper Spec v1.0 (`docs/spec/sdk-helpers.md`).
1102pub const HELPER_SPEC_VERSION: &str = "1.0";
1103
1104#[cfg(test)]
1105mod helper_spec_tests {
1106    use super::HELPER_SPEC_VERSION;
1107
1108    #[test]
1109    fn helper_spec_version_is_pinned() {
1110        assert_eq!(HELPER_SPEC_VERSION, "1.0");
1111    }
1112}