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 connect;
52pub mod connector;
53pub mod error;
54pub mod params;
55pub mod topology;
56pub mod types;
57
58#[cfg(feature = "embedded")]
59pub mod embedded;
60
61#[cfg(feature = "grpc")]
62pub mod grpc;
63
64#[cfg(feature = "grpc")]
65pub mod router;
66
67#[cfg(feature = "redwire")]
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
525#[derive(Debug)]
526pub struct KvClient<'a> {
527    db: &'a Reddb,
528    collection: &'a str,
529}
530
531impl<'a> KvClient<'a> {
532    pub async fn set(&self, key: &str, value: JsonValue) -> Result<QueryResult> {
533        self.put(key, value, &[]).await
534    }
535
536    pub async fn put(&self, key: &str, value: JsonValue, tags: &[&str]) -> Result<QueryResult> {
537        let tag_clause = if tags.is_empty() {
538            String::new()
539        } else {
540            format!(
541                " TAGS [{}]",
542                tags.iter()
543                    .map(|tag| kv_tag_literal(tag))
544                    .collect::<Vec<_>>()
545                    .join(", ")
546            )
547        };
548        self.db
549            .query(&format!(
550                "KV PUT {}.{} = {}{}",
551                kv_collection_identifier(self.collection)?,
552                kv_path_segment(key),
553                json_value_literal(&value),
554                tag_clause
555            ))
556            .await
557    }
558
559    pub async fn get(&self, key: &str) -> Result<Option<KvItem>> {
560        let result = self
561            .db
562            .query(&format!(
563                "KV GET {}.{}",
564                kv_collection_identifier(self.collection)?,
565                kv_path_segment(key)
566            ))
567            .await?;
568        Ok(kv_item_from_rows(&result.rows))
569    }
570
571    pub async fn exists(&self, key: &str) -> Result<ExistsResult> {
572        Ok(ExistsResult {
573            exists: self.get(key).await?.is_some(),
574        })
575    }
576
577    pub async fn delete(&self, key: &str) -> Result<DeleteResult> {
578        let result = self
579            .db
580            .query(&format!(
581                "KV DELETE {}.{}",
582                kv_collection_identifier(self.collection)?,
583                kv_path_segment(key)
584            ))
585            .await?;
586        Ok(DeleteResult {
587            affected: result.affected,
588            deleted: result.affected > 0,
589        })
590    }
591
592    pub async fn list(&self, options: ListOptions<'_>) -> Result<ListResult> {
593        let collection = sql_identifier(self.collection)?;
594        let result = self
595            .db
596            .query(&select_sql(&collection, "key, value", &options))
597            .await?;
598        Ok(ListResult {
599            affected: result.affected,
600            items: result.rows,
601        })
602    }
603
604    pub async fn invalidate_tags(&self, tags: &[&str]) -> Result<u64> {
605        let result = self
606            .db
607            .query(&format!(
608                "INVALIDATE TAGS [{}] FROM {}",
609                tags.iter()
610                    .map(|tag| kv_tag_literal(tag))
611                    .collect::<Vec<_>>()
612                    .join(", "),
613                kv_collection_identifier(self.collection)?
614            ))
615            .await?;
616        Ok(result.affected)
617    }
618
619    pub async fn watch(&self, key: &str) -> Result<Vec<KvWatchEvent>> {
620        self.watch_from_lsn(key, None).await
621    }
622
623    pub async fn watch_from_lsn(
624        &self,
625        key: &str,
626        from_lsn: Option<u64>,
627    ) -> Result<Vec<KvWatchEvent>> {
628        #[cfg(not(feature = "http"))]
629        {
630            let _ = key;
631            let _ = from_lsn;
632            let _ = self.collection;
633        }
634        match self.db {
635            #[cfg(feature = "http")]
636            Reddb::Http(c) => c.watch_kv(self.collection, key, from_lsn, None).await,
637            #[cfg(feature = "embedded")]
638            Reddb::Embedded(_) => Err(ClientError::feature_disabled("kv.watch embedded")),
639            #[cfg(feature = "grpc")]
640            Reddb::Grpc(_) => Err(ClientError::feature_disabled("kv.watch grpc")),
641            Reddb::Unavailable(name) => Err(ClientError::feature_disabled(name)),
642        }
643    }
644
645    pub async fn watch_prefix(&self, prefix: &str) -> Result<Vec<KvWatchEvent>> {
646        self.watch_prefix_from_lsn(prefix, None).await
647    }
648
649    pub async fn watch_prefix_from_lsn(
650        &self,
651        prefix: &str,
652        from_lsn: Option<u64>,
653    ) -> Result<Vec<KvWatchEvent>> {
654        let key = format!("{prefix}.*");
655        self.watch_from_lsn(&key, from_lsn).await
656    }
657}
658
659#[derive(Debug)]
660pub struct ConfigClient<'a> {
661    db: &'a Reddb,
662    collection: &'a str,
663}
664
665impl<'a> ConfigClient<'a> {
666    pub async fn put(&self, key: &str, value: JsonValue, tags: &[&str]) -> Result<QueryResult> {
667        let mut sql = format!(
668            "PUT CONFIG {} {} = {}",
669            kv_collection_identifier(self.collection)?,
670            kv_path_segment(key),
671            json_value_literal(&value)
672        );
673        append_tag_clause(&mut sql, tags);
674        self.db.query(&sql).await
675    }
676
677    pub async fn put_secret_ref(
678        &self,
679        key: &str,
680        vault_collection: &str,
681        vault_key: &str,
682        tags: &[&str],
683    ) -> Result<QueryResult> {
684        let mut sql = format!(
685            "PUT CONFIG {} {} = SECRET_REF(vault, {}.{})",
686            kv_collection_identifier(self.collection)?,
687            kv_path_segment(key),
688            kv_collection_identifier(vault_collection)?,
689            kv_path_segment(vault_key)
690        );
691        append_tag_clause(&mut sql, tags);
692        self.db.query(&sql).await
693    }
694
695    pub async fn get(&self, key: &str) -> Result<QueryResult> {
696        self.db
697            .query(&format!(
698                "GET CONFIG {} {}",
699                kv_collection_identifier(self.collection)?,
700                kv_path_segment(key)
701            ))
702            .await
703    }
704
705    pub async fn resolve(&self, key: &str) -> Result<QueryResult> {
706        self.db
707            .query(&format!(
708                "RESOLVE CONFIG {} {}",
709                kv_collection_identifier(self.collection)?,
710                kv_path_segment(key)
711            ))
712            .await
713    }
714}
715
716#[derive(Debug)]
717pub struct VaultClient<'a> {
718    db: &'a Reddb,
719    collection: &'a str,
720}
721
722impl<'a> VaultClient<'a> {
723    pub async fn put(&self, key: &str, value: JsonValue, tags: &[&str]) -> Result<QueryResult> {
724        let mut sql = format!(
725            "VAULT PUT {}.{} = {}",
726            kv_collection_identifier(self.collection)?,
727            kv_path_segment(key),
728            json_value_literal(&value)
729        );
730        append_tag_clause(&mut sql, tags);
731        self.db.query(&sql).await
732    }
733
734    pub async fn get(&self, key: &str) -> Result<QueryResult> {
735        self.db
736            .query(&format!(
737                "VAULT GET {}.{}",
738                kv_collection_identifier(self.collection)?,
739                kv_path_segment(key)
740            ))
741            .await
742    }
743
744    pub async fn unseal(&self, key: &str) -> Result<QueryResult> {
745        self.db
746            .query(&format!(
747                "UNSEAL VAULT {}.{}",
748                kv_collection_identifier(self.collection)?,
749                kv_path_segment(key)
750            ))
751            .await
752    }
753}
754
755fn append_tag_clause(sql: &mut String, tags: &[&str]) {
756    if tags.is_empty() {
757        return;
758    }
759    sql.push_str(" TAGS [");
760    sql.push_str(
761        &tags
762            .iter()
763            .map(|tag| kv_tag_literal(tag))
764            .collect::<Vec<_>>()
765            .join(", "),
766    );
767    sql.push(']');
768}
769
770fn sql_identifier(value: &str) -> Result<String> {
771    let mut chars = value.chars();
772    let Some(first) = chars.next() else {
773        return Err(ClientError::new(
774            ErrorCode::InvalidArgument,
775            "identifier must not be empty",
776        ));
777    };
778    if !(first.is_ascii_alphabetic() || first == '_') {
779        return Err(ClientError::new(
780            ErrorCode::InvalidArgument,
781            format!("invalid identifier `{value}`"),
782        ));
783    }
784    if chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
785        Ok(value.to_string())
786    } else {
787        Err(ClientError::new(
788            ErrorCode::InvalidArgument,
789            format!("invalid identifier `{value}`"),
790        ))
791    }
792}
793
794fn kv_collection_identifier(value: &str) -> Result<String> {
795    let mut parts = Vec::new();
796    for part in value.split('.') {
797        parts.push(sql_identifier(part)?);
798    }
799    Ok(parts.join("."))
800}
801
802fn kv_path_segment(value: &str) -> String {
803    if is_plain_path_segment(value) {
804        value.to_string()
805    } else {
806        sql_string_literal(value)
807    }
808}
809
810fn is_plain_path_segment(value: &str) -> bool {
811    let mut chars = value.chars();
812    let Some(first) = chars.next() else {
813        return false;
814    };
815    (first.is_ascii_alphabetic() || first == '_')
816        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
817}
818
819fn json_value_literal(value: &JsonValue) -> String {
820    match value {
821        JsonValue::Null => "NULL".to_string(),
822        JsonValue::Bool(value) => value.to_string(),
823        JsonValue::Number(value) => value.to_string(),
824        JsonValue::String(value) => sql_string_literal(value),
825        JsonValue::Array(_) | JsonValue::Object(_) => value.to_json_string(),
826    }
827}
828
829fn json_text_literal(value: &JsonValue) -> String {
830    sql_string_literal(&value.to_json_string())
831}
832
833fn kv_tag_literal(value: &str) -> String {
834    sql_string_literal(value)
835}
836
837fn sql_string_literal(value: &str) -> String {
838    format!("'{}'", value.replace('\'', "''"))
839}
840
841fn ensure_json_object(name: &str, value: &JsonValue) -> Result<()> {
842    if value.as_object().is_some() {
843        Ok(())
844    } else {
845        Err(ClientError::new(
846            ErrorCode::InvalidArgument,
847            format!("{name} must be a JSON object"),
848        ))
849    }
850}
851
852fn select_sql(collection: &str, columns: &str, options: &ListOptions<'_>) -> String {
853    let mut sql = format!("SELECT {columns} FROM {collection}");
854    if let Some(filter) = options.filter {
855        sql.push_str(" WHERE ");
856        sql.push_str(filter);
857    }
858    if let Some(order_by) = options.order_by {
859        sql.push_str(" ORDER BY ");
860        sql.push_str(order_by);
861    }
862    if let Some(limit) = options.limit {
863        sql.push_str(&format!(" LIMIT {limit}"));
864    }
865    sql
866}
867
868fn document_item_from_first_row(result: QueryResult) -> Result<DocumentItem> {
869    let Some(row) = result.rows.into_iter().next() else {
870        return Err(ClientError::new(ErrorCode::NotFound, "document not found"));
871    };
872    let rid = row
873        .iter()
874        .find(|(column, _)| column == "rid")
875        .and_then(|(_, value)| value_as_string(value))
876        .ok_or_else(|| ClientError::new(ErrorCode::InvalidResponse, "document row missing rid"))?;
877    Ok(DocumentItem { rid, fields: row })
878}
879
880fn kv_item_from_rows(rows: &[Row]) -> Option<KvItem> {
881    let row = rows.first()?;
882    let value = row
883        .iter()
884        .find(|(column, _)| column == "value")
885        .map(|(_, value)| value.clone())?;
886    let rid = row
887        .iter()
888        .find(|(column, _)| column == "rid")
889        .map(|(_, value)| value);
890    if matches!(rid, Some(ValueOut::Null)) && value == ValueOut::Null {
891        return None;
892    }
893    let collection = row
894        .iter()
895        .find(|(column, _)| column == "collection")
896        .and_then(|(_, value)| value_as_string(value))
897        .unwrap_or_default();
898    let key = row
899        .iter()
900        .find(|(column, _)| column == "key")
901        .and_then(|(_, value)| value_as_string(value))
902        .unwrap_or_default();
903    Some(KvItem {
904        collection,
905        key,
906        value,
907    })
908}
909
910fn row_value<'a>(rows: &'a [Row], column: &str) -> Option<&'a ValueOut> {
911    rows.first()?
912        .iter()
913        .find(|(name, _)| name == column)
914        .map(|(_, value)| value)
915}
916
917fn value_as_string(value: &ValueOut) -> Option<String> {
918    match value {
919        ValueOut::String(value) => Some(value.clone()),
920        ValueOut::Integer(value) => Some(value.to_string()),
921        _ => None,
922    }
923}
924
925fn value_as_u64(value: &ValueOut) -> Option<u64> {
926    match value {
927        ValueOut::Integer(value) => (*value).try_into().ok(),
928        ValueOut::Float(value) if *value >= 0.0 => Some(*value as u64),
929        ValueOut::String(value) => value.parse().ok(),
930        _ => None,
931    }
932}
933
934/// Crate version (matches the engine version when published in lockstep).
935pub fn version() -> &'static str {
936    env!("CARGO_PKG_VERSION")
937}
938
939/// SDK Helper Spec version this driver conforms to.
940///
941/// Spec §14 asks every official driver to expose a `helper_spec_version`
942/// constant so cross-driver CI dashboards can assert all drivers track the
943/// same contract. Rust is the reference implementation: `reddb-io-client`
944/// ≥ 1.2.0 satisfies Helper Spec v1.0 (`docs/spec/sdk-helpers.md`).
945pub const HELPER_SPEC_VERSION: &str = "1.0";
946
947#[cfg(test)]
948mod helper_spec_tests {
949    use super::HELPER_SPEC_VERSION;
950
951    #[test]
952    fn helper_spec_version_is_pinned() {
953        assert_eq!(HELPER_SPEC_VERSION, "1.0");
954    }
955}