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