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