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