1pub(crate) use crate::application::json_input::{
2 json_bool_field, json_f32_field, json_string_field, json_usize_field,
3};
4pub(crate) use crate::application::{
5 AdminUseCases, CreateEdgeInput, CreateEntityOutput, CreateNodeGraphLinkInput, CreateNodeInput,
6 CreateNodeTableLinkInput, CreateRowInput, CreateVectorInput, DeleteEntityInput, EntityUseCases,
7 ExplainQueryInput, GraphCentralityInput, GraphClusteringInput, GraphCommunitiesInput,
8 GraphComponentsInput, GraphCyclesInput, GraphHitsInput, GraphNeighborhoodInput,
9 GraphPersonalizedPageRankInput, GraphShortestPathInput, GraphTopologicalSortInput,
10 GraphTraversalInput, GraphUseCases, InspectNativeArtifactInput, NativeUseCases,
11 PatchEntityInput, SearchHybridInput, SearchIvfInput, SearchSimilarInput, SearchTextInput,
12};
13use std::collections::BTreeMap;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use crate::api::{RedDBOptions, RedDBResult};
19use crate::auth::middleware::{check_permission, AuthResult, AuthSource};
20use crate::auth::store::AuthStore;
21use crate::auth::Role;
22use crate::health::{HealthProvider, HealthState};
23use crate::json::{
24 from_str as json_from_str, to_string as json_to_string, Map, Value as JsonValue,
25};
26use crate::runtime::query_request::{
27 ParamValue, PreparedId, PreparedRegistry, QueryRequest as RuntimeQueryRequest,
28 QueryRequestExecutor,
29};
30use crate::runtime::{
31 RedDBRuntime, RuntimeFilter, RuntimeFilterValue, RuntimeGraphCentralityAlgorithm,
32 RuntimeGraphCentralityResult, RuntimeGraphClusteringResult, RuntimeGraphCommunityAlgorithm,
33 RuntimeGraphCommunityResult, RuntimeGraphComponentsMode, RuntimeGraphComponentsResult,
34 RuntimeGraphCyclesResult, RuntimeGraphDirection, RuntimeGraphHitsResult,
35 RuntimeGraphNeighborhoodResult, RuntimeGraphPathAlgorithm, RuntimeGraphPathResult,
36 RuntimeGraphPattern, RuntimeGraphProjection, RuntimeGraphTopologicalSortResult,
37 RuntimeGraphTraversalResult, RuntimeGraphTraversalStrategy, RuntimeIvfSearchResult,
38 RuntimeQueryResult, RuntimeQueryWeights, RuntimeStats, ScanPage,
39};
40use crate::storage::unified::devx::refs::{NodeRef, TableRef};
41use crate::storage::unified::{Metadata, MetadataValue};
42use crate::storage::{EntityData, EntityId, UnifiedEntity};
43use prost::Message;
44use reddb_types::Value;
45use tokio_stream::wrappers::TcpListenerStream;
46use tonic::metadata::MetadataMap;
47use tonic::{Request, Response, Status};
48
49pub use reddb_grpc_proto as proto;
55
56use proto::red_db_server::{RedDb, RedDbServer};
57use proto::{
58 ask_stream_event, AskAnswerToken, AskReply, AskRequest, AskSources, AskStreamEvent,
59 BatchInsertChunk, BatchInsertReply, BatchQueryReply, BatchQueryRequest, BulkEntityReply,
60 Citation, CollectionRequest, CollectionsReply, DeleteEntityRequest, DeploymentProfileRequest,
61 Empty, EntityReply, ExecutePreparedRequest, ExportRequest, GraphProjectionUpsertRequest,
62 HealthReply, IndexNameRequest, IndexToggleRequest, JsonBulkCreateRequest, JsonCreateRequest,
63 JsonPayloadRequest, KvWatchEvent, KvWatchRequest, ManifestRequest, OperationReply,
64 PayloadReply, PrepareQueryReply, PrepareQueryRequest, QueryReply, QueryRequest, QueryValue,
65 ScanEntity, ScanReply, ScanRequest, StatsReply, TopologyReply, TopologyRequest,
66 TypedCreateRowRequest, UpdateEntityRequest, Validation, ValidationItem,
67 WalCatchup as GrpcWalCatchup, WalStreamChunk as GrpcWalStreamChunk,
68 WalStreamRecord as GrpcWalStreamRecord, WalStreamRequest as GrpcWalStreamRequest,
69};
70
71pub(crate) mod catalog_dispatch;
72pub(crate) mod control_support;
73mod entity_ops;
74mod input_support;
75pub(crate) mod scan_json;
76
77use self::catalog_dispatch::*;
78use self::control_support::*;
79use self::entity_ops::*;
80use self::input_support::*;
81use self::scan_json::*;
82
83#[derive(Debug, Clone)]
84pub struct GrpcServerOptions {
85 pub bind_addr: String,
86 pub tls: Option<GrpcTlsOptions>,
91}
92
93#[derive(Debug, Clone)]
99pub struct GrpcTlsOptions {
100 pub cert_pem: Vec<u8>,
102 pub key_pem: Vec<u8>,
104 pub client_ca_pem: Option<Vec<u8>>,
109}
110
111impl GrpcTlsOptions {
112 pub fn to_tonic_config(
116 &self,
117 ) -> Result<tonic::transport::ServerTlsConfig, Box<dyn std::error::Error>> {
118 let identity = tonic::transport::Identity::from_pem(&self.cert_pem, &self.key_pem);
119 let mut cfg = tonic::transport::ServerTlsConfig::new().identity(identity);
120 if let Some(ca_pem) = &self.client_ca_pem {
121 cfg = cfg.client_ca_root(tonic::transport::Certificate::from_pem(ca_pem));
122 }
123 Ok(cfg)
124 }
125}
126
127const GRPC_MAX_CONCURRENT_STREAMS: u32 = 256;
130
131const GRPC_CONCURRENCY_LIMIT_PER_CONNECTION: usize = 256;
134
135const GRPC_KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
138const GRPC_KEEPALIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
139
140fn configured_transport_builder() -> tonic::transport::Server {
146 tonic::transport::Server::builder()
147 .max_concurrent_streams(Some(GRPC_MAX_CONCURRENT_STREAMS))
148 .concurrency_limit_per_connection(GRPC_CONCURRENCY_LIMIT_PER_CONNECTION)
149 .http2_keepalive_interval(Some(GRPC_KEEPALIVE_INTERVAL))
150 .http2_keepalive_timeout(Some(GRPC_KEEPALIVE_TIMEOUT))
151}
152
153impl Default for GrpcServerOptions {
154 fn default() -> Self {
155 Self {
156 bind_addr: "127.0.0.1:55055".to_string(),
157 tls: None,
158 }
159 }
160}
161
162#[derive(Clone)]
163pub struct RedDBGrpcServer {
164 runtime: RedDBRuntime,
165 options: GrpcServerOptions,
166 auth_store: Arc<AuthStore>,
167 prepared_registry: Arc<PreparedRegistry>,
168 oauth_validator: Option<Arc<crate::auth::OAuthValidator>>,
174}
175
176impl RedDBGrpcServer {
177 pub fn new(runtime: RedDBRuntime) -> Self {
178 let auth_config = crate::auth::AuthConfig::default();
179 let auth_store = Arc::new(AuthStore::new(auth_config));
180 Self::with_options(runtime, GrpcServerOptions::default(), auth_store)
181 }
182
183 pub fn from_database_options(
184 db_options: RedDBOptions,
185 options: GrpcServerOptions,
186 ) -> RedDBResult<Self> {
187 let runtime = RedDBRuntime::with_options(db_options.clone())?;
189
190 let auth_store = if db_options.auth.vault_enabled {
191 let pager = runtime.db().store().pager().cloned().ok_or_else(|| {
195 crate::api::RedDBError::Internal(
196 "vault requires a paged database (persistent mode)".into(),
197 )
198 })?;
199 let store = AuthStore::with_vault(db_options.auth.clone(), pager)
200 .map_err(|e| crate::api::RedDBError::Internal(e.to_string()))?;
201 Arc::new(store)
202 } else {
203 Arc::new(AuthStore::new(db_options.auth.clone()))
204 };
205 auth_store.bootstrap_from_env();
206 Ok(Self::with_options(runtime, options, auth_store))
207 }
208
209 pub fn with_options(
210 runtime: RedDBRuntime,
211 options: GrpcServerOptions,
212 auth_store: Arc<AuthStore>,
213 ) -> Self {
214 runtime.set_auth_store(Arc::clone(&auth_store));
217 Self {
218 runtime,
219 options,
220 auth_store,
221 prepared_registry: Arc::new(PreparedRegistry::new()),
222 oauth_validator: None,
223 }
224 }
225
226 pub fn with_oauth_validator(mut self, validator: Arc<crate::auth::OAuthValidator>) -> Self {
232 self.oauth_validator = Some(validator);
233 self
234 }
235
236 pub fn oauth_validator(&self) -> Option<&Arc<crate::auth::OAuthValidator>> {
238 self.oauth_validator.as_ref()
239 }
240
241 pub fn runtime(&self) -> &RedDBRuntime {
242 &self.runtime
243 }
244
245 pub fn options(&self) -> &GrpcServerOptions {
246 &self.options
247 }
248
249 pub fn auth_store(&self) -> &Arc<AuthStore> {
250 &self.auth_store
251 }
252
253 pub(crate) fn with_listener_options(mut self, options: GrpcServerOptions) -> Self {
254 self.options = options;
255 self
256 }
257
258 fn grpc_runtime(&self) -> GrpcRuntime {
259 GrpcRuntime {
260 runtime: self.runtime.clone(),
261 auth_store: self.auth_store.clone(),
262 prepared_registry: Arc::clone(&self.prepared_registry),
263 oauth_validator: self.oauth_validator.clone(),
264 }
265 }
266
267 pub async fn serve(&self) -> Result<(), Box<dyn std::error::Error>> {
268 let addr = self.options.bind_addr.parse()?;
269 let mut builder = configured_transport_builder();
270 if let Some(tls) = &self.options.tls {
271 log_grpc_tls_identity(tls);
274 builder = builder.tls_config(tls.to_tonic_config()?)?;
275 }
276 builder
277 .add_service(Self::configured_service(self.grpc_runtime()))
278 .serve(addr)
279 .await?;
280 Ok(())
281 }
282
283 pub async fn serve_on(
284 &self,
285 listener: std::net::TcpListener,
286 ) -> Result<(), Box<dyn std::error::Error>> {
287 listener.set_nonblocking(true)?;
288 let listener = tokio::net::TcpListener::from_std(listener)?;
289 let incoming = TcpListenerStream::new(listener);
290 let mut builder = configured_transport_builder();
291 if let Some(tls) = &self.options.tls {
292 log_grpc_tls_identity(tls);
293 builder = builder.tls_config(tls.to_tonic_config()?)?;
294 }
295 builder
296 .add_service(Self::configured_service(self.grpc_runtime()))
297 .serve_with_incoming(incoming)
298 .await?;
299 Ok(())
300 }
301
302 pub(crate) async fn serve_router_demux(
309 &self,
310 rx: tokio::sync::mpsc::Receiver<tokio::net::TcpStream>,
311 ) -> Result<(), Box<dyn std::error::Error>> {
312 use tokio_stream::StreamExt;
313 let incoming = tokio_stream::wrappers::ReceiverStream::new(rx).map(Ok::<_, std::io::Error>);
314 let mut builder = configured_transport_builder();
315 if let Some(tls) = &self.options.tls {
316 log_grpc_tls_identity(tls);
317 builder = builder.tls_config(tls.to_tonic_config()?)?;
318 }
319 builder
320 .add_service(Self::configured_service(self.grpc_runtime()))
321 .serve_with_incoming(incoming)
322 .await?;
323 Ok(())
324 }
325
326 pub(crate) fn max_message_bytes() -> usize {
332 const DEFAULT: usize = 32 * 1024 * 1024;
333 std::env::var("REDDB_GRPC_MAX_MESSAGE_BYTES")
334 .ok()
335 .and_then(|raw| raw.trim().parse::<usize>().ok())
336 .filter(|value| *value > 0)
337 .unwrap_or(DEFAULT)
338 }
339
340 fn configured_service(runtime: GrpcRuntime) -> GrpcCatalogService {
341 use tonic::codec::CompressionEncoding;
345 let max_message = Self::max_message_bytes();
346 let service = RedDbServer::new(runtime.clone())
347 .max_decoding_message_size(max_message)
348 .max_encoding_message_size(max_message)
349 .accept_compressed(CompressionEncoding::Zstd)
350 .accept_compressed(CompressionEncoding::Gzip)
351 .send_compressed(CompressionEncoding::Zstd);
352 GrpcCatalogService::new(service, runtime)
353 }
354}
355
356#[derive(Clone)]
357struct GrpcRuntime {
358 runtime: RedDBRuntime,
359 auth_store: Arc<AuthStore>,
360 prepared_registry: Arc<PreparedRegistry>,
361 oauth_validator: Option<Arc<crate::auth::OAuthValidator>>,
365}
366
367#[derive(Clone)]
368struct ResolvedGrpcAuth {
369 result: AuthResult,
370 identity: Option<(crate::auth::UserId, Role)>,
371}
372
373impl GrpcRuntime {
374 fn admin_use_cases(&self) -> AdminUseCases<'_, RedDBRuntime> {
375 AdminUseCases::new(&self.runtime)
376 }
377
378 fn entity_use_cases(&self) -> EntityUseCases<'_, RedDBRuntime> {
379 EntityUseCases::new(&self.runtime)
380 }
381
382 fn graph_use_cases(&self) -> GraphUseCases<'_, RedDBRuntime> {
383 GraphUseCases::new(&self.runtime)
384 }
385
386 fn native_use_cases(&self) -> NativeUseCases<'_, RedDBRuntime> {
387 NativeUseCases::new(&self.runtime)
388 }
389}
390
391fn grpc_query_value_to_param_value(value: QueryValue) -> Result<ParamValue, Status> {
392 use proto::query_value::Kind;
393
394 match value
395 .kind
396 .ok_or_else(|| Status::invalid_argument("missing query param value"))?
397 {
398 Kind::NullValue(_) => Ok(ParamValue::Null),
399 Kind::BoolValue(value) => Ok(ParamValue::Bool(value)),
400 Kind::IntValue(value) => Ok(ParamValue::Int64(value)),
401 Kind::FloatValue(value) => Ok(ParamValue::Float64(value)),
402 Kind::TextValue(value) => Ok(ParamValue::Text(value)),
403 Kind::BytesValue(value) => Ok(ParamValue::Bytes(value)),
404 Kind::VectorValue(value) => Ok(ParamValue::Vector(value.values)),
405 Kind::JsonValue(value) => {
406 let parsed = json_from_str::<JsonValue>(&value)
407 .map_err(|e| Status::invalid_argument(format!("json param parse error: {e}")))?;
408 let encoded = json_to_string(&parsed)
409 .map_err(|e| Status::invalid_argument(format!("json param encode error: {e}")))?;
410 Ok(ParamValue::Json(encoded.into_bytes()))
411 }
412 Kind::TimestampValue(value) => Ok(ParamValue::Timestamp(value)),
413 Kind::UuidValue(value) => {
414 let bytes: [u8; 16] = value.try_into().map_err(|value: Vec<u8>| {
415 Status::invalid_argument(format!(
416 "uuid param must be 16 bytes, got {}",
417 value.len()
418 ))
419 })?;
420 Ok(ParamValue::Uuid(bytes))
421 }
422 }
423}
424
425struct GrpcIdentityGuard {
431 previous_identity: Option<(String, crate::auth::Role)>,
432 previous_principal: Option<crate::auth::UserId>,
433 previous_tenant: Option<String>,
434}
435
436impl GrpcIdentityGuard {
437 fn install(identity: Option<(crate::auth::UserId, crate::auth::Role)>) -> Self {
438 use crate::runtime::execution_context as ctx;
439 let previous_identity = ctx::current_auth_identity_for_audit();
440 let previous_principal = ctx::current_auth_principal();
441 let previous_tenant = ctx::current_tenant();
442 match identity {
443 Some((id, role)) => {
444 match &id.tenant {
445 Some(tenant) => ctx::set_current_tenant(tenant.clone()),
446 None => ctx::clear_current_tenant(),
447 }
448 ctx::set_current_auth_principal(id, role);
449 }
450 None => {
451 ctx::clear_current_auth_identity();
452 ctx::clear_current_tenant();
453 }
454 }
455 Self {
456 previous_identity,
457 previous_principal,
458 previous_tenant,
459 }
460 }
461}
462
463impl Drop for GrpcIdentityGuard {
464 fn drop(&mut self) {
465 use crate::runtime::execution_context as ctx;
466 match (
467 self.previous_principal.take(),
468 self.previous_identity.take(),
469 ) {
470 (Some(principal), Some((_, role))) => ctx::set_current_auth_principal(principal, role),
471 _ => ctx::clear_current_auth_identity(),
472 }
473 match self.previous_tenant.take() {
474 Some(tenant) => ctx::set_current_tenant(tenant),
475 None => ctx::clear_current_tenant(),
476 }
477 }
478}
479
480fn grpc_request_identity<T>(
483 request: &Request<T>,
484) -> Option<(crate::auth::UserId, crate::auth::Role)> {
485 request
486 .extensions()
487 .get::<catalog_dispatch::GrpcDispatchContext>()
488 .and_then(|context| context.identity.clone())
489}
490
491async fn grpc_spawn_blocking<F, R>(runtime: &RedDBRuntime, f: F) -> Result<R, Status>
492where
493 F: FnOnce() -> R + Send + 'static,
494 R: Send + 'static,
495{
496 use crate::transport::execution_admission::ExecutionAdmissionError;
497
498 let admission = runtime.execution_admission();
499 let permit =
500 match tokio::time::timeout(runtime.execution_queue_timeout(), admission.acquire()).await {
501 Ok(Ok(permit)) => permit,
502 Ok(Err(ExecutionAdmissionError::QueueFull)) => {
503 return Err(Status::resource_exhausted(
504 "execution_queue_full: engine execution queue is full",
505 ))
506 }
507 Ok(Err(ExecutionAdmissionError::Closed)) => {
508 return Err(Status::unavailable(
509 "execution_admission_closed: engine execution admission is unavailable",
510 ))
511 }
512 Err(_) => {
513 return Err(Status::deadline_exceeded(
514 "execution_queue_timeout: deadline expired while waiting to execute",
515 ))
516 }
517 };
518 let bundle = crate::runtime::execution_context::snapshot_bundle();
519 tokio::task::spawn_blocking(move || {
520 let _permit = permit;
521 crate::runtime::execution_context::with_snapshot_bundle(&bundle, f)
522 })
523 .await
524 .map_err(|error| Status::internal(format!("engine execution task failed: {error}")))
525}
526
527fn execute_grpc_query_request(
528 runtime: &RedDBRuntime,
529 prepared: &PreparedRegistry,
530 identity: Option<(crate::auth::UserId, crate::auth::Role)>,
531 query: String,
532 params: Vec<QueryValue>,
533 commit_policy: Option<crate::replication::CommitPolicy>,
534) -> Result<crate::runtime::OwnedQueryResult, Status> {
535 if query.trim().is_empty() {
536 return Err(Status::invalid_argument("query field cannot be empty"));
537 }
538 let _identity = GrpcIdentityGuard::install(identity);
539
540 let params = params
541 .into_iter()
542 .map(grpc_query_value_to_param_value)
543 .collect::<Result<Vec<_>, _>>()?;
544 let mut request = RuntimeQueryRequest::sql(query, params);
545 if let Some(commit_policy) = commit_policy {
546 request = request.with_commit_policy(commit_policy);
547 }
548 QueryRequestExecutor::new(runtime, prepared)
549 .execute(request)
550 .map_err(grpc_query_request_error)
551}
552
553fn grpc_commit_policy_from_metadata(
554 metadata: &MetadataMap,
555) -> Result<Option<crate::replication::CommitPolicy>, Status> {
556 let Some(value) = metadata.get("x-reddb-commit-policy") else {
557 return Ok(None);
558 };
559 let value = value
560 .to_str()
561 .map_err(|_| Status::invalid_argument("x-reddb-commit-policy must be ascii"))?;
562 crate::replication::CommitPolicy::parse_strict(value)
563 .map(Some)
564 .ok_or_else(|| Status::invalid_argument(format!("invalid commit policy '{value}'")))
565}
566
567fn grpc_json_bind_to_param_value(value: &str) -> Result<ParamValue, Status> {
568 let value = json_from_str::<JsonValue>(value)
569 .map_err(|error| Status::invalid_argument(format!("bind parse error: {error}")))?;
570 Ok(match value {
571 JsonValue::Null => ParamValue::Null,
572 JsonValue::Bool(value) => ParamValue::Bool(value),
573 JsonValue::Integer(value) => ParamValue::Int64(value),
574 JsonValue::Number(value) if value.fract() == 0.0 && value.abs() < i64::MAX as f64 => {
575 ParamValue::Int64(value as i64)
576 }
577 JsonValue::Number(value) => ParamValue::Float64(value),
578 JsonValue::String(value) => ParamValue::Text(value),
579 other => ParamValue::Text(json_to_string(&other).unwrap_or_default()),
580 })
581}
582
583fn grpc_query_request_error(error: crate::api::RedDBError) -> Status {
584 let message = error.to_string();
585 if message.contains("commit policy timed out") {
586 return Status::deadline_exceeded(message);
587 }
588 match error {
589 crate::api::RedDBError::Query(message) if message.contains("not found or expired") => {
590 Status::not_found(message)
591 }
592 crate::api::RedDBError::Query(message)
593 if message == "prepared_needs_replan" || message == "prepared statements disabled" =>
594 {
595 Status::failed_precondition(message)
596 }
597 crate::api::RedDBError::Query(message)
600 if message.contains("prepared bind failed")
601 || message.contains("weaker than resolved floor") =>
602 {
603 Status::invalid_argument(message)
604 }
605 crate::api::RedDBError::Validation { message, .. } => Status::invalid_argument(message),
606 other => to_status(other),
610 }
611}
612
613#[cfg(test)]
614mod grpc_query_value_tests {
615 use super::*;
616 use proto::query_value::Kind;
617
618 fn mutation_field(name: &str, kind: Kind) -> proto::MutationField {
619 proto::MutationField {
620 name: name.to_string(),
621 value: Some(QueryValue { kind: Some(kind) }),
622 }
623 }
624
625 #[tokio::test]
626 async fn grpc_execution_refuses_when_shared_runtime_queue_is_full() {
627 let runtime =
628 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
629 runtime.configure_execution_admission(1, 0, 100);
630 let admission = runtime.execution_admission();
631 let active = admission.acquire().await.expect("active slot");
632
633 let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
634 assert_eq!(status.code(), tonic::Code::ResourceExhausted);
635 assert!(status.message().contains("execution_queue_full"));
636 drop(active);
637 }
638
639 #[tokio::test]
640 async fn grpc_execution_reports_queue_deadline_separately() {
641 let runtime =
642 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
643 runtime.configure_execution_admission(1, 1, 1);
644 let admission = runtime.execution_admission();
645 let active = admission.acquire().await.expect("active slot");
646
647 let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
648 assert_eq!(status.code(), tonic::Code::DeadlineExceeded);
649 assert!(status.message().contains("execution_queue_timeout"));
650 drop(active);
651 }
652
653 #[test]
654 fn grpc_query_value_maps_to_request_param_variants() {
655 let cases = vec![
656 (
657 QueryValue {
658 kind: Some(Kind::NullValue(proto::QueryNull {})),
659 },
660 ParamValue::Null,
661 ),
662 (
663 QueryValue {
664 kind: Some(Kind::BoolValue(true)),
665 },
666 ParamValue::Bool(true),
667 ),
668 (
669 QueryValue {
670 kind: Some(Kind::IntValue(42)),
671 },
672 ParamValue::Int64(42),
673 ),
674 (
675 QueryValue {
676 kind: Some(Kind::FloatValue(1.5)),
677 },
678 ParamValue::Float64(1.5),
679 ),
680 (
681 QueryValue {
682 kind: Some(Kind::BytesValue(vec![0, 1, 2])),
683 },
684 ParamValue::Bytes(vec![0, 1, 2]),
685 ),
686 (
687 QueryValue {
688 kind: Some(Kind::VectorValue(proto::QueryVector {
689 values: vec![0.25, 0.5],
690 })),
691 },
692 ParamValue::Vector(vec![0.25, 0.5]),
693 ),
694 (
695 QueryValue {
696 kind: Some(Kind::TimestampValue(1_779_999_000)),
697 },
698 ParamValue::Timestamp(1_779_999_000),
699 ),
700 (
701 QueryValue {
702 kind: Some(Kind::UuidValue(vec![0x11; 16])),
703 },
704 ParamValue::Uuid([0x11; 16]),
705 ),
706 ];
707
708 for (input, expected) in cases {
709 assert_eq!(grpc_query_value_to_param_value(input).unwrap(), expected);
710 }
711
712 assert_eq!(
713 grpc_query_value_to_param_value(QueryValue {
714 kind: Some(Kind::TextValue("alice".into())),
715 })
716 .unwrap(),
717 ParamValue::Text("alice".to_string())
718 );
719 assert_eq!(
720 grpc_query_value_to_param_value(QueryValue {
721 kind: Some(Kind::JsonValue("{\"role\":\"admin\"}".into())),
722 })
723 .unwrap(),
724 ParamValue::Json(b"{\"role\":\"admin\"}".to_vec())
725 );
726 }
727
728 #[test]
729 fn grpc_query_value_rejects_missing_kind_and_bad_uuid() {
730 assert!(grpc_query_value_to_param_value(QueryValue { kind: None }).is_err());
731 assert!(grpc_query_value_to_param_value(QueryValue {
732 kind: Some(Kind::UuidValue(vec![0; 15])),
733 })
734 .is_err());
735 }
736
737 #[test]
738 fn grpc_query_rejects_empty_query_before_runtime_parse() {
739 let runtime =
740 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
741 let prepared = PreparedRegistry::new();
742 let err = execute_grpc_query_request(
743 &runtime,
744 &prepared,
745 None,
746 " ".to_string(),
747 Vec::new(),
748 None,
749 )
750 .expect_err("empty query should fail");
751
752 assert_eq!(err.code(), tonic::Code::InvalidArgument);
753 assert_eq!(err.message(), "query field cannot be empty");
754 }
755
756 #[test]
757 fn grpc_query_params_are_bound_before_execution() {
758 let runtime =
759 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
760 seed_grpc_param_table(&runtime);
761 let prepared = PreparedRegistry::new();
762
763 let result = execute_grpc_query_request(
764 &runtime,
765 &prepared,
766 None,
767 "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
768 grpc_param_values(),
769 None,
770 )
771 .expect("parameterized query");
772
773 assert_eq!(result.result.records.len(), 1);
774 }
775
776 #[test]
777 fn grpc_query_enforces_ack_n_commit_policy_fail_closed() {
778 let _env_lock = env_lock().lock().expect("env lock");
779 let _env = EnvGuard::set(&[
780 ("RED_PRIMARY_COMMIT_POLICY", "ack_n=1"),
781 ("RED_REPLICATION_ACK_TIMEOUT_MS", "20"),
782 ("RED_COMMIT_FAIL_ON_TIMEOUT", "false"),
783 ]);
784 let data_path = temp_data_path("grpc_ack_n_timeout");
785 cleanup(&data_path);
786
787 let runtime = RedDBRuntime::with_options(
788 crate::api::RedDBOptions::persistent(&data_path)
789 .with_replication(crate::replication::ReplicationConfig::primary()),
790 )
791 .expect("runtime");
792 let prepared = PreparedRegistry::new();
793
794 let err = execute_grpc_query_request(
795 &runtime,
796 &prepared,
797 None,
798 "INSERT INTO grpc_ack_items (id, name) VALUES (1, 'alpha')".to_string(),
799 Vec::new(),
800 None,
801 )
802 .expect_err("ack_n without replica ack must fail closed");
803
804 assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
805 assert!(
806 err.message().contains("commit policy timed out")
807 && err.message().contains("local commit is durable"),
808 "error should identify commit policy timeout, got {err:?}"
809 );
810 assert!(
811 runtime.cdc_current_lsn() > 0,
812 "local mutation should advance CDC before gRPC response fails"
813 );
814
815 cleanup(&data_path);
816 }
817
818 #[tokio::test]
819 async fn grpc_query_rpc_binds_query_request_params() {
820 let runtime =
821 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
822 seed_grpc_param_table(&runtime);
823 let service = GrpcRuntime {
824 runtime,
825 auth_store: Arc::new(AuthStore::new(crate::auth::AuthConfig::default())),
826 prepared_registry: Arc::new(PreparedRegistry::new()),
827 oauth_validator: None,
828 };
829
830 let reply = RedDb::query(
831 &service,
832 Request::new(QueryRequest {
833 query: "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
834 entity_types: Vec::new(),
835 capabilities: Vec::new(),
836 params: grpc_param_values(),
837 }),
838 )
839 .await
840 .expect("query rpc")
841 .into_inner();
842
843 assert_eq!(reply.record_count, 1);
844 assert!(reply.result_json.contains("Alice"), "{}", reply.result_json);
845 assert!(!reply.result_json.contains("Bob"), "{}", reply.result_json);
846 }
847
848 #[tokio::test]
849 async fn grpc_typed_create_row_preserves_types_and_uses_contract_defaults() {
850 let runtime =
851 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
852 runtime
853 .execute_query(
854 "CREATE TABLE typed_users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, active BOOLEAN DEFAULT = true)",
855 )
856 .expect("create typed table");
857 let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
858
859 let reply = RedDb::create_row_typed(
860 &service,
861 Request::new(TypedCreateRowRequest {
862 collection: "typed_users".to_string(),
863 fields: vec![
864 mutation_field("id", Kind::IntValue(7)),
865 mutation_field("name", Kind::TextValue("Ada".to_string())),
866 ],
867 }),
868 )
869 .await
870 .expect("typed insert")
871 .into_inner();
872
873 assert!(reply.ok);
874 let selected = runtime
875 .execute_query("SELECT id, name, active FROM typed_users WHERE id = 7")
876 .expect("select typed row");
877 assert_eq!(selected.result.records.len(), 1);
878 assert_eq!(
879 selected.result.records[0].get("id"),
880 Some(&Value::Integer(7))
881 );
882 assert_eq!(
883 selected.result.records[0].get("name"),
884 Some(&Value::text("Ada"))
885 );
886 assert_eq!(
887 selected.result.records[0].get("active"),
888 Some(&Value::Boolean(true))
889 );
890 }
891
892 #[tokio::test]
893 async fn grpc_typed_create_row_rejects_ambiguous_or_invalid_fields_before_write() {
894 let runtime =
895 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
896 runtime
897 .execute_query(
898 "CREATE TABLE typed_rejects (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
899 )
900 .expect("create typed table");
901 let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
902
903 let duplicate = RedDb::create_row_typed(
904 &service,
905 Request::new(TypedCreateRowRequest {
906 collection: "typed_rejects".to_string(),
907 fields: vec![
908 mutation_field("id", Kind::IntValue(1)),
909 mutation_field("id", Kind::IntValue(2)),
910 mutation_field("name", Kind::TextValue("Ada".to_string())),
911 ],
912 }),
913 )
914 .await
915 .expect_err("duplicate field must fail");
916 assert_eq!(duplicate.code(), tonic::Code::InvalidArgument);
917 assert!(duplicate.message().contains("duplicate field 'id'"));
918
919 let missing_kind = RedDb::create_row_typed(
920 &service,
921 Request::new(TypedCreateRowRequest {
922 collection: "typed_rejects".to_string(),
923 fields: vec![proto::MutationField {
924 name: "id".to_string(),
925 value: Some(QueryValue { kind: None }),
926 }],
927 }),
928 )
929 .await
930 .expect_err("missing value kind must fail");
931 assert_eq!(missing_kind.code(), tonic::Code::InvalidArgument);
932
933 let selected = runtime
934 .execute_query("SELECT id FROM typed_rejects")
935 .expect("select after rejected inserts");
936 assert!(selected.result.records.is_empty());
937 }
938
939 #[tokio::test]
940 async fn grpc_prepared_id_is_shared_by_connection_listeners_only() {
941 let runtime =
942 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
943 let server = RedDBGrpcServer::new(runtime.clone());
944 let plain_listener = server.grpc_runtime();
945 let tls_listener = server.grpc_runtime();
946 let second_connection = RedDBGrpcServer::new(runtime).grpc_runtime();
947
948 let prepared = RedDb::prepare_query(
949 &plain_listener,
950 Request::new(PrepareQueryRequest {
951 query: "SELECT 7 AS value".to_string(),
952 }),
953 )
954 .await
955 .expect("prepare on plaintext listener")
956 .into_inner();
957
958 let reply = RedDb::execute_prepared(
959 &tls_listener,
960 Request::new(ExecutePreparedRequest {
961 prepared_id: prepared.prepared_id,
962 bind_json: vec!["42".to_string()],
963 }),
964 )
965 .await
966 .expect("execute on TLS listener of the same connection")
967 .into_inner();
968 assert!(reply.result_json.contains("42"), "{}", reply.result_json);
969
970 let error = RedDb::execute_prepared(
971 &second_connection,
972 Request::new(ExecutePreparedRequest {
973 prepared_id: prepared.prepared_id,
974 bind_json: vec!["42".to_string()],
975 }),
976 )
977 .await
978 .expect_err("another connection must not resolve the prepared ID");
979 assert_eq!(error.code(), tonic::Code::NotFound);
980 }
981
982 #[tokio::test]
983 async fn grpc_execute_prepared_rejects_a_stale_ddl_epoch() {
984 let runtime =
985 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
986 let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
987 let prepared = RedDb::prepare_query(
988 &service,
989 Request::new(PrepareQueryRequest {
990 query: "SELECT 7 AS value".to_string(),
991 }),
992 )
993 .await
994 .expect("prepare query")
995 .into_inner();
996
997 runtime
998 .execute_query("CREATE TABLE grpc_prepared_epoch (id INTEGER)")
999 .expect("execute DDL");
1000
1001 let error = RedDb::execute_prepared(
1002 &service,
1003 Request::new(ExecutePreparedRequest {
1004 prepared_id: prepared.prepared_id,
1005 bind_json: vec!["42".to_string()],
1006 }),
1007 )
1008 .await
1009 .expect_err("DDL must invalidate the gRPC prepared shape");
1010 assert_eq!(error.code(), tonic::Code::FailedPrecondition);
1011 assert_eq!(error.message(), "prepared_needs_replan");
1012 }
1013
1014 #[tokio::test]
1015 async fn grpc_prepared_kill_switch_rejects_prepare_and_execute() {
1016 let runtime =
1017 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
1018 let service = RedDBGrpcServer::new(runtime).grpc_runtime();
1019 let prepared = RedDb::prepare_query(
1020 &service,
1021 Request::new(PrepareQueryRequest {
1022 query: "SELECT 7 AS value".to_string(),
1023 }),
1024 )
1025 .await
1026 .expect("prepare before kill switch")
1027 .into_inner();
1028
1029 service.prepared_registry.disable();
1030
1031 let prepare_error = RedDb::prepare_query(
1032 &service,
1033 Request::new(PrepareQueryRequest {
1034 query: "SELECT 1".to_string(),
1035 }),
1036 )
1037 .await
1038 .expect_err("kill switch must reject prepare");
1039 assert_eq!(prepare_error.code(), tonic::Code::FailedPrecondition);
1040 assert_eq!(prepare_error.message(), "prepared statements disabled");
1041
1042 let execute_error = RedDb::execute_prepared(
1043 &service,
1044 Request::new(ExecutePreparedRequest {
1045 prepared_id: prepared.prepared_id,
1046 bind_json: vec!["42".to_string()],
1047 }),
1048 )
1049 .await
1050 .expect_err("kill switch must reject execute");
1051 assert_eq!(execute_error.code(), tonic::Code::FailedPrecondition);
1052 assert_eq!(execute_error.message(), "prepared statements disabled");
1053 }
1054
1055 #[tokio::test]
1056 async fn grpc_query_honors_per_request_commit_policy() {
1057 let _env_lock = env_lock().lock().expect("env lock");
1058 let _env = EnvGuard::set(&[("RED_PRIMARY_COMMIT_POLICY", "ack_n=1")]);
1059 let runtime = RedDBRuntime::with_options(
1060 crate::api::RedDBOptions::in_memory()
1061 .with_replication(crate::replication::ReplicationConfig::primary()),
1062 )
1063 .expect("runtime");
1064 runtime
1065 .execute_query("CREATE TABLE grpc_request_policy (id INTEGER)")
1066 .expect("create table");
1067 let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
1068 let mut request = Request::new(QueryRequest {
1069 query: "INSERT INTO grpc_request_policy (id) VALUES (1)".to_string(),
1070 entity_types: Vec::new(),
1071 capabilities: Vec::new(),
1072 params: Vec::new(),
1073 });
1074 request
1075 .metadata_mut()
1076 .insert("x-reddb-commit-policy", "local".parse().expect("metadata"));
1077
1078 let error = RedDb::query(&service, request)
1079 .await
1080 .expect_err("weaker per-request policy must fail before mutation");
1081 assert_eq!(error.code(), tonic::Code::InvalidArgument);
1082 assert!(error.message().contains("weaker than resolved floor"));
1083
1084 let rows = runtime
1085 .execute_query("SELECT id FROM grpc_request_policy")
1086 .expect("read rows");
1087 assert!(rows.result.records.is_empty());
1088 }
1089
1090 #[tokio::test]
1091 async fn pull_wal_records_rejects_stale_term_on_grpc_path() {
1092 let runtime = RedDBRuntime::with_options(
1093 crate::api::RedDBOptions::in_memory()
1094 .with_replication(crate::replication::ReplicationConfig::primary().with_term(6)),
1095 )
1096 .expect("runtime");
1097 let auth_store = Arc::new(AuthStore::new(crate::auth::AuthConfig {
1098 enabled: true,
1099 require_auth: true,
1100 ..crate::auth::AuthConfig::default()
1101 }));
1102 let bootstrap = auth_store
1103 .bootstrap("replica", "secret")
1104 .expect("bootstrap");
1105 let policy = crate::auth::policies::Policy::from_json_str(
1106 r#"{
1107 "id": "replication-stream",
1108 "version": 1,
1109 "statements": [{
1110 "effect": "allow",
1111 "actions": ["cluster:replication:stream"],
1112 "resources": ["cluster:replication"]
1113 }]
1114 }"#,
1115 )
1116 .expect("policy");
1117 auth_store.put_policy(policy).expect("install policy");
1118 auth_store
1119 .attach_policy(
1120 crate::auth::store::PrincipalRef::User(crate::auth::UserId::platform("replica")),
1121 "replication-stream",
1122 )
1123 .expect("attach policy");
1124 let service = GrpcRuntime {
1125 runtime,
1126 auth_store,
1127 prepared_registry: Arc::new(PreparedRegistry::new()),
1128 oauth_validator: None,
1129 };
1130
1131 let open = reddb_wire::replication::WalStreamOpen {
1132 since_lsn: 0,
1133 max_count: 1,
1134 replica_id: Some("replica-a".to_string()),
1135 installed_snapshot_token: None,
1136 term: 5,
1137 await_data: false,
1138 await_timeout_ms: 1,
1139 };
1140 let mut request = Request::new(JsonPayloadRequest {
1141 payload_json: String::from_utf8(open.encode_json()).expect("json"),
1142 });
1143 request.metadata_mut().insert(
1144 "authorization",
1145 format!("Bearer {}", bootstrap.api_key.key)
1146 .parse()
1147 .expect("metadata"),
1148 );
1149
1150 let err = RedDb::pull_wal_records(&service, request)
1151 .await
1152 .expect_err("stale term should be fenced");
1153
1154 assert_eq!(err.code(), tonic::Code::FailedPrecondition);
1155 assert!(
1156 err.message().contains("stale")
1157 || err.message().contains("fenced")
1158 || err.message().contains("current term"),
1159 "unexpected stale-term error: {err:?}"
1160 );
1161 }
1162
1163 fn seed_grpc_param_table(runtime: &RedDBRuntime) {
1164 runtime
1165 .execute_query("CREATE TABLE p (id INTEGER, name TEXT)")
1166 .expect("create table");
1167 runtime
1168 .execute_query("INSERT INTO p (id, name) VALUES (1, 'Alice')")
1169 .expect("insert alice");
1170 runtime
1171 .execute_query("INSERT INTO p (id, name) VALUES (2, 'Bob')")
1172 .expect("insert bob");
1173 }
1174
1175 fn grpc_param_values() -> Vec<QueryValue> {
1176 vec![
1177 QueryValue {
1178 kind: Some(Kind::IntValue(1)),
1179 },
1180 QueryValue {
1181 kind: Some(Kind::TextValue("Alice".to_string())),
1182 },
1183 ]
1184 }
1185
1186 fn env_lock() -> &'static std::sync::Mutex<()> {
1187 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1188 LOCK.get_or_init(|| std::sync::Mutex::new(()))
1189 }
1190
1191 struct EnvGuard {
1192 previous: Vec<(&'static str, Option<String>)>,
1193 }
1194
1195 impl EnvGuard {
1196 fn set(vars: &[(&'static str, &'static str)]) -> Self {
1197 let previous = vars
1198 .iter()
1199 .map(|(key, _)| (*key, std::env::var(key).ok()))
1200 .collect();
1201 for (key, value) in vars {
1202 std::env::set_var(key, value);
1203 }
1204 Self { previous }
1205 }
1206 }
1207
1208 impl Drop for EnvGuard {
1209 fn drop(&mut self) {
1210 for (key, value) in self.previous.iter().rev() {
1211 match value {
1212 Some(value) => std::env::set_var(key, value),
1213 None => std::env::remove_var(key),
1214 }
1215 }
1216 }
1217 }
1218
1219 fn temp_data_path(name: &str) -> std::path::PathBuf {
1220 let suffix = SystemTime::now()
1221 .duration_since(UNIX_EPOCH)
1222 .unwrap()
1223 .as_nanos();
1224 std::env::temp_dir().join(format!("reddb_{name}_{suffix}.rdb"))
1225 }
1226
1227 fn cleanup(data_path: &std::path::Path) {
1228 let _ = std::fs::remove_file(data_path);
1229 let _ = std::fs::remove_file(
1230 crate::replication::primary::PrimaryReplication::slot_path_for(data_path),
1231 );
1232 let _ = std::fs::remove_file(crate::replication::primary::LogicalWalSpool::path_for(
1233 data_path,
1234 ));
1235 let _ = std::fs::remove_dir_all(
1236 crate::replication::primary::PrimaryReplication::primary_replica_root_for(data_path),
1237 );
1238 reddb_file::cleanup_rebootstrap_artifacts(data_path);
1239 }
1240}
1241
1242#[cfg(test)]
1243mod grpc_ask_query_reply_tests {
1244 use super::*;
1245 use crate::storage::query::unified::{UnifiedRecord, UnifiedResult};
1246 use reddb_rql::modes::QueryMode;
1247 use reddb_types::Value as SchemaValue;
1248
1249 fn ask_runtime_result() -> RuntimeQueryResult {
1250 let mut result = UnifiedResult::with_columns(vec![
1251 "answer".into(),
1252 "provider".into(),
1253 "model".into(),
1254 "mode".into(),
1255 "retry_count".into(),
1256 "prompt_tokens".into(),
1257 "completion_tokens".into(),
1258 "sources_flat".into(),
1259 "citations".into(),
1260 "validation".into(),
1261 ]);
1262 let mut record = UnifiedRecord::new();
1263 record.set("answer", SchemaValue::text("Deploy failed [^1]."));
1264 record.set("provider", SchemaValue::text("openai"));
1265 record.set("model", SchemaValue::text("gpt-4o-mini"));
1266 record.set("mode", SchemaValue::text("strict"));
1267 record.set("retry_count", SchemaValue::Integer(0));
1268 record.set("prompt_tokens", SchemaValue::Integer(11));
1269 record.set("completion_tokens", SchemaValue::Integer(7));
1270 record.set(
1271 "sources_flat",
1272 SchemaValue::Json(
1273 br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
1274 ),
1275 );
1276 record.set(
1277 "citations",
1278 SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
1279 );
1280 record.set(
1281 "validation",
1282 SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
1283 );
1284 result.push(record);
1285
1286 RuntimeQueryResult {
1287 query: "ASK 'why did deploy fail?'".to_string(),
1288 mode: QueryMode::Sql,
1289 statement: "ask",
1290 engine: "runtime-ai",
1291 result,
1292 affected_rows: 0,
1293 statement_type: "select",
1294 bookmark: None,
1295 notice: None,
1296 }
1297 }
1298
1299 #[test]
1300 fn query_reply_ask_result_json_uses_full_canonical_schema() {
1301 let reply = query_reply(ask_runtime_result(), &None, &None);
1302 let json: crate::json::Value =
1303 crate::json::from_str(&reply.result_json).expect("valid ask json");
1304
1305 assert_eq!(
1306 json.get("answer").and_then(crate::json::Value::as_str),
1307 Some("Deploy failed [^1].")
1308 );
1309 assert_eq!(
1310 json.get("cache_hit").and_then(crate::json::Value::as_bool),
1311 Some(false)
1312 );
1313 assert_eq!(
1314 json.get("cost_usd").and_then(crate::json::Value::as_f64),
1315 Some(0.0)
1316 );
1317 assert_eq!(
1318 json.get("mode").and_then(crate::json::Value::as_str),
1319 Some("strict")
1320 );
1321 assert_eq!(
1322 json.get("retry_count").and_then(crate::json::Value::as_u64),
1323 Some(0)
1324 );
1325 assert!(
1326 json.get("records").is_none(),
1327 "ASK must not be row-wrapped: {}",
1328 reply.result_json
1329 );
1330 assert!(
1331 json.get("sources_flat")
1332 .and_then(crate::json::Value::as_array)
1333 .is_some_and(|sources| sources.len() == 1
1334 && sources[0]
1335 .get("payload")
1336 .and_then(crate::json::Value::as_str)
1337 .is_some()),
1338 "sources_flat must be parsed with payload fallback: {}",
1339 reply.result_json
1340 );
1341 assert!(
1342 json.get("citations")
1343 .and_then(crate::json::Value::as_array)
1344 .is_some_and(|citations| citations.len() == 1),
1345 "citations must be parsed: {}",
1346 reply.result_json
1347 );
1348 assert_eq!(
1349 json.get("validation")
1350 .and_then(|v| v.get("ok"))
1351 .and_then(crate::json::Value::as_bool),
1352 Some(true)
1353 );
1354 }
1355
1356 #[test]
1357 fn query_reply_non_ask_answer_column_keeps_row_shape() {
1358 let mut result = UnifiedResult::with_columns(vec!["answer".into()]);
1359 let mut record = UnifiedRecord::new();
1360 record.set("answer", SchemaValue::text("plain select"));
1361 result.push(record);
1362
1363 let reply = query_reply(
1364 RuntimeQueryResult {
1365 query: "SELECT 'plain select' AS answer".to_string(),
1366 mode: QueryMode::Sql,
1367 statement: "select",
1368 engine: "runtime-sql",
1369 result,
1370 affected_rows: 0,
1371 statement_type: "select",
1372 bookmark: None,
1373 notice: None,
1374 },
1375 &None,
1376 &None,
1377 );
1378 let json: crate::json::Value =
1379 crate::json::from_str(&reply.result_json).expect("valid query json");
1380
1381 assert!(
1382 json.get("records").is_some(),
1383 "non-ASK must stay row-wrapped"
1384 );
1385 assert!(
1386 json.get("answer").is_none(),
1387 "non-ASK must not use ASK envelope"
1388 );
1389 }
1390
1391 #[test]
1392 fn query_reply_non_ask_json_column_preserves_object() {
1393 let mut result = UnifiedResult::with_columns(vec!["value".into()]);
1394 let mut record = UnifiedRecord::new();
1395 record.set(
1396 "value",
1397 SchemaValue::Json(br#"{"alpha":"A","nested":{"leaf":12}}"#.to_vec()),
1398 );
1399 result.push(record);
1400
1401 let reply = query_reply(
1402 RuntimeQueryResult {
1403 query: "LIST KV proj AS JSON".to_string(),
1404 mode: QueryMode::Sql,
1405 statement: "kv_list_json",
1406 engine: "kv",
1407 result,
1408 affected_rows: 0,
1409 statement_type: "select",
1410 bookmark: None,
1411 notice: None,
1412 },
1413 &None,
1414 &None,
1415 );
1416 let json: crate::json::Value =
1417 crate::json::from_str(&reply.result_json).expect("valid query json");
1418 let value = json
1419 .get("records")
1420 .and_then(crate::json::Value::as_array)
1421 .and_then(|records| records.first())
1422 .and_then(|record| record.get("value"))
1423 .expect("value column");
1424
1425 assert_eq!(
1426 value.get("alpha").and_then(crate::json::Value::as_str),
1427 Some("A")
1428 );
1429 assert_eq!(
1430 value
1431 .get("nested")
1432 .and_then(|nested| nested.get("leaf"))
1433 .and_then(crate::json::Value::as_f64),
1434 Some(12.0)
1435 );
1436 }
1437}
1438
1439fn log_grpc_tls_identity(tls: &GrpcTlsOptions) {
1442 use sha2::{Digest, Sha256};
1443 let cert_fp = {
1444 let mut h = Sha256::new();
1445 h.update(&tls.cert_pem);
1446 let digest = h.finalize();
1447 let mut buf = String::with_capacity(64);
1450 for b in digest.iter() {
1451 buf.push_str(&format!("{b:02x}"));
1452 }
1453 buf
1454 };
1455 tracing::info!(
1456 target: "reddb::security",
1457 transport = "grpc",
1458 cert_sha256 = %cert_fp,
1459 mtls = tls.client_ca_pem.is_some(),
1460 "gRPC TLS identity loaded"
1461 );
1462}
1463
1464include!("grpc/service_impl.rs");