Skip to main content

statelet_sdk/
lib.rs

1// `ConsumeError<E>` carries a `tonic::Status` in its `Transport` variant, which
2// puts the enum over clippy's `result_large_err` threshold (~176 bytes) at every
3// `Result<_, ConsumeError<E>>` in cdc.rs. The suggested fix is to box it, but
4// that reshapes a public type callers match on, so the lint is turned off here
5// rather than the API broken.
6#![allow(clippy::result_large_err)]
7
8//! Statelet Rust SDK — async gRPC client.
9//!
10//! # Example
11//!
12//! ```no_run
13//! use statelet_sdk::StateletClient;
14//!
15//! #[tokio::main]
16//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
17//!     let mut client = StateletClient::connect("http://127.0.0.1:7379").await?;
18//!
19//!     // Ping
20//!     println!("{}", client.ping().await?);
21//!
22//!     // KV operations
23//!     client.put(b"hello", b"world", None).await?;
24//!     if let Some(value) = client.get(b"hello", None).await? {
25//!         println!("got: {:?}", value);
26//!     }
27//!     client.delete(b"hello", None).await?;
28//!     Ok(())
29//! }
30//! ```
31
32pub mod proto {
33    // Generated by tonic-build from proto/statelet.proto. Lints are suppressed
34    // wholesale because the contents are not ours to edit: doc comments here
35    // are verbatim proto comments, and their formatting trips
36    // `doc_lazy_continuation` on every regeneration.
37    #![allow(clippy::all)]
38    #![allow(clippy::doc_lazy_continuation)]
39
40    tonic::include_proto!("statelet.v1");
41}
42
43pub mod cdc;
44pub use cdc::{
45    CheckpointStore, CommittedChange, ConsumeError, FeedItem, FeedStream, FeedTransport,
46    FileCheckpointStore, SubscribeCommittedOptions,
47};
48
49use proto::statelet_client::StateletClient as GrpcClient;
50use tonic::transport::Channel;
51
52/// A single nearest-neighbor search result.
53#[derive(Debug, Clone)]
54pub struct VectorSearchResult {
55    pub id: u64,
56    pub distance: f32,
57    /// Field-collapse group key (epic #1427). Empty unless the search set
58    /// [`GroupSpec::field`]; otherwise the candidate's `group_field` payload
59    /// value, rendered to its canonical string. Re-bucket on this to present
60    /// results grouped.
61    pub group_key: String,
62}
63
64/// Result grouping / field-collapse options for [`StateletClient::vector_search_grouped`]
65/// (epic #1427). Collapse results to at most `group_size` hits per distinct
66/// value of the payload field `field`, returning up to `groups` distinct group
67/// keys, ordered by ascending distance.
68#[derive(Debug, Clone, Default)]
69pub struct GroupSpec {
70    /// Payload field to group by (empty ⇒ grouping off).
71    pub field: String,
72    /// Max hits per group (`0` ⇒ 1, one-best-per-group).
73    pub group_size: u32,
74    /// Number of distinct group keys to return (`0` ⇒ falls back to `k`).
75    pub groups: u32,
76    /// Candidate over-fetch multiplier (`0` ⇒ default 4, capped server-side).
77    pub overfetch: u32,
78    /// When `true`, candidates missing `field` are returned as their own
79    /// singleton group (empty `group_key`) instead of being dropped (default).
80    pub missing_as_own: bool,
81}
82
83/// HNSW vector index configuration.
84#[derive(Debug, Clone)]
85pub struct VectorIndexConfig {
86    pub dim: u32,
87    pub metric: i32, // 0=L2, 1=Cosine, 2=InnerProduct
88    pub m: u32,
89    pub m_max0: u32,
90    pub ef_construction: u32,
91    pub ef_search: u32,
92}
93
94impl Default for VectorIndexConfig {
95    fn default() -> Self {
96        Self {
97            dim: 128,
98            metric: 0,
99            m: 16,
100            m_max0: 0,
101            ef_construction: 200,
102            ef_search: 64,
103        }
104    }
105}
106
107/// Batch write operation.
108pub enum WriteOp {
109    Put {
110        cf: u32,
111        key: Vec<u8>,
112        value: Vec<u8>,
113    },
114    Delete {
115        cf: u32,
116        key: Vec<u8>,
117    },
118    Merge {
119        cf: u32,
120        key: Vec<u8>,
121        value: Vec<u8>,
122    },
123}
124
125/// Out-of-band knobs for [`StateletClient::graph_query`]. The default means
126/// "let the gateway decide": the default graph, no extra row cap, and no
127/// temporal filter on either axis.
128#[derive(Debug, Clone, Default)]
129pub struct GraphQueryOptions {
130    /// Graph index to query (empty ⇒ the gateway's default graph).
131    pub graph_name: String,
132    /// Hard cap on returned rows regardless of any `LIMIT` in the query
133    /// (`0` ⇒ no extra cap; a parsed `LIMIT` still applies).
134    pub max_rows: u32,
135    /// Valid-time the query is evaluated against, in ms (`0` ⇒ current). An
136    /// `AS OF` clause in the query text overrides it.
137    pub as_of: u64,
138    /// Transaction-time the query is evaluated against, in ms (`0` ⇒ current).
139    pub tx_as_of: u64,
140}
141
142/// One projected column value. `Json` carries the hydrated `ROLE_NodeProp`
143/// blob for a whole node, verbatim.
144#[derive(Debug, Clone, PartialEq)]
145pub enum GraphValue {
146    Null,
147    Int(i64),
148    Double(f64),
149    Str(String),
150    Bool(bool),
151    Json(Vec<u8>),
152}
153
154impl GraphValue {
155    /// Decode the wire union, reading the member the `kind` tag selects. An
156    /// unknown tag (a newer server) decodes to [`GraphValue::Null`].
157    fn from_proto(value: proto::GraphQueryValue) -> Self {
158        use proto::graph_query_value::Kind;
159        match Kind::try_from(value.kind) {
160            Ok(Kind::Int) => GraphValue::Int(value.int_value),
161            Ok(Kind::Double) => GraphValue::Double(value.dbl_value),
162            Ok(Kind::String) => GraphValue::Str(value.str_value),
163            Ok(Kind::Bool) => GraphValue::Bool(value.bool_value),
164            Ok(Kind::Json) => GraphValue::Json(value.json_value),
165            Ok(Kind::Null) | Err(_) => GraphValue::Null,
166        }
167    }
168}
169
170/// The projected result set of a [`StateletClient::graph_query`].
171///
172/// `warnings` is non-empty when the result may be incomplete — e.g. a label
173/// scan hit the per-shard frontier cap, so the anchor set was truncated.
174#[derive(Debug, Clone, Default)]
175pub struct GraphQueryResult {
176    /// `RETURN` column names, in projection order.
177    pub columns: Vec<String>,
178    /// Result rows, each in `columns` order.
179    pub rows: Vec<Vec<GraphValue>>,
180    /// Non-fatal query warnings.
181    pub warnings: Vec<String>,
182}
183
184/// Async gRPC client for Statelet.
185pub struct StateletClient {
186    inner: GrpcClient<Channel>,
187    default_cf: u32,
188}
189
190impl StateletClient {
191    /// Connect to a Statelet node.
192    pub async fn connect(addr: &str) -> Result<Self, tonic::transport::Error> {
193        let inner = GrpcClient::connect(addr.to_string()).await?;
194        Ok(Self {
195            inner,
196            default_cf: 0,
197        })
198    }
199
200    /// Set the default column family id.
201    pub fn set_default_cf(&mut self, cf: u32) {
202        self.default_cf = cf;
203    }
204
205    // ── KV operations ───────────────────────────────────────────────
206
207    /// Liveness check. Returns "PONG".
208    pub async fn ping(&mut self) -> Result<String, tonic::Status> {
209        let resp = self.inner.ping(proto::PingRequest {}).await?;
210        Ok(resp.into_inner().message)
211    }
212
213    /// Write a single key-value pair.
214    pub async fn put(
215        &mut self,
216        key: &[u8],
217        value: &[u8],
218        cf: Option<u32>,
219    ) -> Result<(), tonic::Status> {
220        self.inner
221            .put(proto::PutRequest {
222                cf: cf.unwrap_or(self.default_cf),
223                key: key.to_vec(),
224                value: value.to_vec(),
225                ..Default::default()
226            })
227            .await?;
228        Ok(())
229    }
230
231    /// Read the value for a key. Returns `None` if not found.
232    pub async fn get(
233        &mut self,
234        key: &[u8],
235        cf: Option<u32>,
236    ) -> Result<Option<Vec<u8>>, tonic::Status> {
237        let resp = self
238            .inner
239            .get(proto::GetRequest {
240                cf: cf.unwrap_or(self.default_cf),
241                key: key.to_vec(),
242                ..Default::default()
243            })
244            .await?
245            .into_inner();
246        Ok(if resp.found { Some(resp.value) } else { None })
247    }
248
249    /// Delete a key.
250    pub async fn delete(&mut self, key: &[u8], cf: Option<u32>) -> Result<(), tonic::Status> {
251        self.inner
252            .delete(proto::DeleteRequest {
253                cf: cf.unwrap_or(self.default_cf),
254                key: key.to_vec(),
255                ..Default::default()
256            })
257            .await?;
258        Ok(())
259    }
260
261    /// Merge an operand into the existing value.
262    pub async fn merge(
263        &mut self,
264        key: &[u8],
265        value: &[u8],
266        cf: Option<u32>,
267    ) -> Result<(), tonic::Status> {
268        self.inner
269            .merge(proto::MergeRequest {
270                cf: cf.unwrap_or(self.default_cf),
271                key: key.to_vec(),
272                value: value.to_vec(),
273                ..Default::default()
274            })
275            .await?;
276        Ok(())
277    }
278
279    /// Atomically apply a batch of write operations.
280    pub async fn batch_write(&mut self, ops: Vec<WriteOp>) -> Result<(), tonic::Status> {
281        let entries = ops
282            .into_iter()
283            .map(|op| match op {
284                WriteOp::Put { cf, key, value } => proto::WriteEntry {
285                    cf,
286                    op: proto::WriteOp::Put as i32,
287                    key,
288                    value,
289                    ..Default::default()
290                },
291                WriteOp::Delete { cf, key } => proto::WriteEntry {
292                    cf,
293                    op: proto::WriteOp::Delete as i32,
294                    key,
295                    value: vec![],
296                    ..Default::default()
297                },
298                WriteOp::Merge { cf, key, value } => proto::WriteEntry {
299                    cf,
300                    op: proto::WriteOp::Merge as i32,
301                    key,
302                    value,
303                    ..Default::default()
304                },
305            })
306            .collect();
307        self.inner
308            .batch_write(proto::BatchWriteRequest {
309                entries,
310                ..Default::default()
311            })
312            .await?;
313        Ok(())
314    }
315
316    /// Scan keys with an optional prefix filter. Returns entries and next cursor.
317    pub async fn scan(
318        &mut self,
319        prefix: &[u8],
320        cursor: Option<&[u8]>,
321        limit: u32,
322        cf: Option<u32>,
323    ) -> Result<(Vec<(Vec<u8>, Vec<u8>)>, Option<Vec<u8>>), tonic::Status> {
324        let resp = self
325            .inner
326            .scan(proto::ScanRequest {
327                cf: cf.unwrap_or(self.default_cf),
328                prefix: prefix.to_vec(),
329                cursor: cursor.unwrap_or(&[]).to_vec(),
330                limit,
331                ..Default::default()
332            })
333            .await?
334            .into_inner();
335        let entries = resp.entries.into_iter().map(|e| (e.key, e.value)).collect();
336        let next = if resp.next_cursor.is_empty() {
337            None
338        } else {
339            Some(resp.next_cursor)
340        };
341        Ok((entries, next))
342    }
343
344    /// Delete all keys matching a prefix. Returns the number of keys deleted.
345    pub async fn delete_by_prefix(
346        &mut self,
347        prefix: &[u8],
348        cf: Option<u32>,
349    ) -> Result<u32, tonic::Status> {
350        let resp = self
351            .inner
352            .delete_by_prefix(proto::DeleteByPrefixRequest {
353                cf: cf.unwrap_or(self.default_cf),
354                prefix: prefix.to_vec(),
355                ..Default::default()
356            })
357            .await?
358            .into_inner();
359        Ok(resp.deleted)
360    }
361
362    // ── Vector operations ───────────────────────────────────────────
363
364    /// Create or reconfigure an HNSW vector index.
365    pub async fn create_vector_index(
366        &mut self,
367        name: &str,
368        config: VectorIndexConfig,
369    ) -> Result<(), tonic::Status> {
370        self.inner
371            .create_vector_index(proto::CreateVectorIndexRequest {
372                index_name: name.to_string(),
373                config: Some(proto::VectorIndexConfig {
374                    dim: config.dim,
375                    metric: config.metric,
376                    m: config.m,
377                    m_max0: config.m_max0,
378                    ef_construction: config.ef_construction,
379                    ef_search: config.ef_search,
380                    ..Default::default()
381                }),
382            })
383            .await?;
384        Ok(())
385    }
386
387    /// Drop an HNSW vector index.
388    pub async fn drop_vector_index(&mut self, name: &str) -> Result<(), tonic::Status> {
389        self.inner
390            .drop_vector_index(proto::DropVectorIndexRequest {
391                index_name: name.to_string(),
392            })
393            .await?;
394        Ok(())
395    }
396
397    /// Insert or update a vector.
398    pub async fn vector_put(
399        &mut self,
400        index_name: &str,
401        vector_id: u64,
402        vector: Vec<f32>,
403    ) -> Result<(), tonic::Status> {
404        self.inner
405            .vector_put(proto::VectorPutRequest {
406                index_name: index_name.to_string(),
407                vector_id,
408                vector,
409                attributes: Default::default(),
410            })
411            .await?;
412        Ok(())
413    }
414
415    /// Remove a vector from the index.
416    pub async fn vector_delete(
417        &mut self,
418        index_name: &str,
419        vector_id: u64,
420    ) -> Result<(), tonic::Status> {
421        self.inner
422            .vector_delete(proto::VectorDeleteRequest {
423                index_name: index_name.to_string(),
424                vector_id,
425            })
426            .await?;
427        Ok(())
428    }
429
430    /// Approximate nearest neighbor search.
431    pub async fn vector_search(
432        &mut self,
433        index_name: &str,
434        query: Vec<f32>,
435        k: u32,
436        ef_search: Option<u32>,
437    ) -> Result<Vec<VectorSearchResult>, tonic::Status> {
438        self.vector_search_reranked(index_name, query, k, ef_search, None)
439            .await
440    }
441
442    /// Approximate nearest neighbor search with an optional second-stage
443    /// reranker.
444    ///
445    /// Pass a [`proto::RerankSpec`] to enable the cross-encoder or model-free
446    /// score-fusion rerank over an over-fetched candidate window — the analogue
447    /// of Weaviate `.with_additional({rerank})` / Pinecone `inference.rerank`.
448    /// `None` ⇒ no rerank (identical to [`Self::vector_search`]). See
449    /// `docs/reranking.md` for the two models and `signal_blend` semantics.
450    pub async fn vector_search_reranked(
451        &mut self,
452        index_name: &str,
453        query: Vec<f32>,
454        k: u32,
455        ef_search: Option<u32>,
456        rerank: Option<proto::RerankSpec>,
457    ) -> Result<Vec<VectorSearchResult>, tonic::Status> {
458        let resp = self
459            .inner
460            .vector_search(proto::VectorSearchRequest {
461                index_name: index_name.to_string(),
462                query,
463                k,
464                ef_search: ef_search.unwrap_or(0),
465                filter: None,
466                query_payload: None, // single-vector ANN path (no multi-vector MaxSim)
467                mmr: false,          // MMR diversity rerank off (omit ⇒ default behavior)
468                mmr_lambda: 0.0,
469                mmr_pool: 0,
470                rerank,                     // optional second-stage rerank
471                planner_override: 0,        // 0 ⇒ let the server pick the plan
472                group_field: String::new(), // grouping off (see vector_search_grouped)
473                group_size: 0,
474                groups: 0,
475                group_overfetch: 0,
476                group_missing_as_own: false,
477            })
478            .await?
479            .into_inner();
480        Ok(resp
481            .results
482            .into_iter()
483            .map(|r| VectorSearchResult {
484                id: r.id,
485                distance: r.distance,
486                group_key: r.group_key,
487            })
488            .collect())
489    }
490
491    /// Approximate nearest neighbor search with result grouping / field-collapse
492    /// (epic #1427).
493    ///
494    /// Collapse results to at most [`GroupSpec::group_size`] hits per distinct
495    /// value of [`GroupSpec::field`], returning up to [`GroupSpec::groups`]
496    /// distinct group keys (each result's value surfaced on
497    /// [`VectorSearchResult::group_key`]). Grouping is exact on single-shard
498    /// deployments and best-effort across shards (tune via [`GroupSpec::overfetch`]).
499    /// Grouping is mutually exclusive with MMR. The analogue of Qdrant
500    /// `query_groups` / Weaviate `groupBy` / Milvus `grouping_field`.
501    pub async fn vector_search_grouped(
502        &mut self,
503        index_name: &str,
504        query: Vec<f32>,
505        k: u32,
506        ef_search: Option<u32>,
507        group: GroupSpec,
508    ) -> Result<Vec<VectorSearchResult>, tonic::Status> {
509        let resp = self
510            .inner
511            .vector_search(proto::VectorSearchRequest {
512                index_name: index_name.to_string(),
513                query,
514                k,
515                ef_search: ef_search.unwrap_or(0),
516                filter: None,
517                query_payload: None,
518                mmr: false,
519                mmr_lambda: 0.0,
520                mmr_pool: 0,
521                rerank: None,
522                planner_override: 0,
523                group_field: group.field,
524                group_size: group.group_size,
525                groups: group.groups,
526                group_overfetch: group.overfetch,
527                group_missing_as_own: group.missing_as_own,
528            })
529            .await?
530            .into_inner();
531        Ok(resp
532            .results
533            .into_iter()
534            .map(|r| VectorSearchResult {
535                id: r.id,
536                distance: r.distance,
537                group_key: r.group_key,
538            })
539            .collect())
540    }
541
542    /// Dry-run pre-flight validation of a [`proto::RerankSpec`].
543    ///
544    /// Issues a `validate_only` vector search that validates the
545    /// `passage_field` template (and, for `model = "cross-encoder"`, that a
546    /// reranker is loaded on the gateway) without executing the search.
547    /// Returns `Ok(())` when the spec is valid; the underlying
548    /// `InvalidArgument` / `FailedPrecondition` [`tonic::Status`] otherwise.
549    /// Mirrors Weaviate's "property exists?" / Pinecone's "rank_fields valid?"
550    /// pre-flight.
551    pub async fn rerank_validate(
552        &mut self,
553        index_name: &str,
554        mut rerank: proto::RerankSpec,
555    ) -> Result<(), tonic::Status> {
556        rerank.enabled = true;
557        rerank.validate_only = true;
558        self.inner
559            .vector_search(proto::VectorSearchRequest {
560                index_name: index_name.to_string(),
561                query: Vec::new(),
562                k: 1,
563                ef_search: 0,
564                filter: None,
565                query_payload: None,
566                mmr: false,
567                mmr_lambda: 0.0,
568                mmr_pool: 0,
569                rerank: Some(rerank),
570                planner_override: 0,
571                group_field: String::new(),
572                group_size: 0,
573                groups: 0,
574                group_overfetch: 0,
575                group_missing_as_own: false,
576            })
577            .await?;
578        Ok(())
579    }
580
581    /// Retrieve a stored vector by id.
582    pub async fn vector_get(
583        &mut self,
584        index_name: &str,
585        vector_id: u64,
586    ) -> Result<Option<Vec<f32>>, tonic::Status> {
587        let resp = self
588            .inner
589            .vector_get(proto::VectorGetRequest {
590                index_name: index_name.to_string(),
591                vector_id,
592            })
593            .await?
594            .into_inner();
595        Ok(if resp.found { Some(resp.vector) } else { None })
596    }
597
598    // ── Declarative graph query (openCypher subset) ─────────────────────
599
600    /// Run a read-only openCypher-subset query.
601    ///
602    /// Gateway-only: the gateway parses and plans the query, then compiles it to
603    /// engine traversal primitives. The subset covers `MATCH` path patterns,
604    /// `WHERE` over node properties, `RETURN` / `ORDER BY` / `LIMIT`, a
605    /// bitemporal `AS OF <valid>[, <tx>]` clause, and the retrieval procedures
606    /// `db.vectorSearch` / `db.hybridSearch` / `db.graphRag`. `CREATE` / `MERGE`
607    /// are rejected.
608    ///
609    /// [`GraphQueryOptions::default()`] means "let the gateway decide": the
610    /// default graph, no extra row cap and no temporal filter on either axis.
611    ///
612    /// Named query parameters (`$q`) parse but are not resolvable yet, so a
613    /// vector-seeded procedure needs an inline literal —
614    /// `db.vectorSearch([0.1, 0.2, ...], 5)`.
615    pub async fn graph_query(
616        &mut self,
617        cypher: &str,
618        options: GraphQueryOptions,
619    ) -> Result<GraphQueryResult, tonic::Status> {
620        let resp = self
621            .inner
622            .graph_query(proto::GraphQueryRequest {
623                graph_name: options.graph_name,
624                cypher: cypher.to_string(),
625                max_rows: options.max_rows,
626                as_of: options.as_of,
627                tx_as_of: options.tx_as_of,
628            })
629            .await?
630            .into_inner();
631        Ok(GraphQueryResult {
632            columns: resp.columns,
633            rows: resp
634                .rows
635                .into_iter()
636                .map(|row| row.values.into_iter().map(GraphValue::from_proto).collect())
637                .collect(),
638            warnings: resp.warnings,
639        })
640    }
641
642    // ── Durable change-feed (CDC) — issue #824 ──────────────────────────
643
644    /// Consume the durable, ordered, resumable committed change-feed (CDC).
645    ///
646    /// Invokes `handler` for each [`cdc::CommittedChange`] in stable Raft-offset
647    /// order, driving the canonical Phase-5b algorithm: client-managed offsets
648    /// (supply `subscription_id` + `checkpoint` to resume across restarts),
649    /// bootstrap-on-`compacted` via a paged [`Self::scan`], heartbeat-advances-
650    /// checkpoint, reconnect-on-disconnect from `last_offset + 1`, and
651    /// at-least-once delivery (with `auto_commit`, each offset is committed
652    /// *after* `handler` returns `Ok(true)`).
653    ///
654    /// `handler` returns `Ok(true)` to continue, `Ok(false)` to stop cleanly, or
655    /// `Err(e)` to stop with [`cdc::ConsumeError::Handler`]. The future runs
656    /// until the handler stops it (the live tail never ends on its own).
657    pub async fn subscribe_committed<H, E>(
658        &mut self,
659        opts: cdc::SubscribeCommittedOptions<'_>,
660        handler: H,
661    ) -> Result<(), cdc::ConsumeError<E>>
662    where
663        H: FnMut(cdc::CommittedChange) -> Result<bool, E>,
664    {
665        let default_cf = self.default_cf;
666        let mut sleeper = cdc::TokioSleeper;
667        cdc::run_consumer(self, &mut sleeper, opts, default_cf, handler).await
668    }
669}
670
671/// A live gRPC committed-feed stream, wrapping `tonic::Streaming`.
672pub struct GrpcFeedStream {
673    inner: tonic::Streaming<proto::CommittedFeedItem>,
674}
675
676#[tonic::async_trait]
677impl cdc::FeedStream for GrpcFeedStream {
678    async fn recv(&mut self) -> Result<Option<cdc::FeedItem>, tonic::Status> {
679        match self.inner.message().await? {
680            Some(item) => Ok(cdc::FeedItem::from_proto(item)),
681            None => Ok(None),
682        }
683    }
684}
685
686#[tonic::async_trait]
687impl cdc::FeedTransport for StateletClient {
688    type Stream = GrpcFeedStream;
689
690    async fn open_feed(
691        &mut self,
692        shard_id: u64,
693        from_offset: u64,
694        cf: u32,
695        key_prefix: &[u8],
696        include_values: bool,
697    ) -> Result<Self::Stream, tonic::Status> {
698        let resp = self
699            .inner
700            .subscribe_committed(proto::SubscribeCommittedRequest {
701                shard_id,
702                from_offset,
703                cf,
704                key_prefix: key_prefix.to_vec(),
705                include_values,
706            })
707            .await?;
708        Ok(GrpcFeedStream {
709            inner: resp.into_inner(),
710        })
711    }
712
713    async fn scan_page(
714        &mut self,
715        prefix: &[u8],
716        cursor: Option<&[u8]>,
717        limit: u32,
718        cf: u32,
719    ) -> Result<(Vec<(Vec<u8>, Vec<u8>)>, Option<Vec<u8>>), tonic::Status> {
720        self.scan(prefix, cursor, limit, Some(cf)).await
721    }
722}
723
724#[cfg(test)]
725mod graph_query_tests {
726    use super::*;
727
728    fn value(kind: proto::graph_query_value::Kind) -> proto::GraphQueryValue {
729        proto::GraphQueryValue {
730            kind: kind as i32,
731            int_value: 42,
732            dbl_value: 0.5,
733            str_value: "knows".to_string(),
734            bool_value: true,
735            json_value: br#"{"name":"ada"}"#.to_vec(),
736        }
737    }
738
739    #[test]
740    fn decodes_every_value_kind() {
741        use proto::graph_query_value::Kind;
742        assert_eq!(GraphValue::from_proto(value(Kind::Null)), GraphValue::Null);
743        assert_eq!(
744            GraphValue::from_proto(value(Kind::Int)),
745            GraphValue::Int(42)
746        );
747        assert_eq!(
748            GraphValue::from_proto(value(Kind::Double)),
749            GraphValue::Double(0.5)
750        );
751        assert_eq!(
752            GraphValue::from_proto(value(Kind::String)),
753            GraphValue::Str("knows".to_string())
754        );
755        assert_eq!(
756            GraphValue::from_proto(value(Kind::Bool)),
757            GraphValue::Bool(true)
758        );
759        assert_eq!(
760            GraphValue::from_proto(value(Kind::Json)),
761            GraphValue::Json(br#"{"name":"ada"}"#.to_vec())
762        );
763    }
764
765    #[test]
766    fn unknown_kind_from_a_newer_server_decodes_to_null() {
767        let mut v = value(proto::graph_query_value::Kind::Int);
768        v.kind = 99;
769        assert_eq!(GraphValue::from_proto(v), GraphValue::Null);
770    }
771
772    #[test]
773    fn default_options_leave_every_knob_at_the_server_default() {
774        let o = GraphQueryOptions::default();
775        assert!(o.graph_name.is_empty());
776        assert_eq!((o.max_rows, o.as_of, o.tx_as_of), (0, 0, 0));
777    }
778}