Skip to main content

reddb_server/
grpc.rs

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
49// gRPC protobuf types and tonic stubs live in the standalone
50// `reddb-grpc-proto` crate so `reddb-server` and `reddb-client`
51// can both consume them without a dependency cycle. We expose
52// them under the legacy `proto` module path so existing
53// `crate::grpc::proto::…` imports keep resolving.
54pub 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    UpdateEntityRequest, Validation, ValidationItem, WalCatchup as GrpcWalCatchup,
67    WalStreamChunk as GrpcWalStreamChunk, WalStreamRecord as GrpcWalStreamRecord,
68    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    /// Optional TLS configuration. When set the server terminates
87    /// TLS for inbound gRPC traffic via `tonic::transport::ServerTlsConfig`.
88    /// When `None`, the listener stays plaintext (back-compat for
89    /// loopback / sidecar deployments where a sidecar terminates TLS).
90    pub tls: Option<GrpcTlsOptions>,
91}
92
93/// PEM-encoded TLS material for gRPC's tonic-rustls server.
94///
95/// The server identity is required (cert + key); the optional
96/// client-CA enables mTLS — when present, tonic verifies and
97/// requires a client cert chain that anchors at this CA bundle.
98#[derive(Debug, Clone)]
99pub struct GrpcTlsOptions {
100    /// PEM bytes for the server certificate chain (leaf first).
101    pub cert_pem: Vec<u8>,
102    /// PEM bytes for the server private key (PKCS#8 / SEC1 / RSA).
103    pub key_pem: Vec<u8>,
104    /// Optional PEM bytes for the trust anchor used to verify
105    /// client certificates. When `Some(_)`, the server requires
106    /// every client to present a cert that chains to this CA;
107    /// when `None`, the server runs one-way TLS only.
108    pub client_ca_pem: Option<Vec<u8>>,
109}
110
111impl GrpcTlsOptions {
112    /// Build a `tonic` `ServerTlsConfig` from PEM bytes, applying
113    /// rustls defaults (TLS 1.2 + 1.3 — older versions are not
114    /// negotiable on tokio-rustls 0.26).
115    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
127/// Per-connection ceiling on concurrent HTTP/2 streams. tonic's default is
128/// unbounded, so one connection could open arbitrarily many RPCs.
129const GRPC_MAX_CONCURRENT_STREAMS: u32 = 256;
130
131/// Ceiling on concurrent in-flight requests per connection, mirroring the
132/// HTTP edge's in-flight cap.
133const GRPC_CONCURRENCY_LIMIT_PER_CONNECTION: usize = 256;
134
135/// Keepalive ping cadence and reply grace. Without these a half-open
136/// connection is only reclaimed when the OS notices.
137const 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
140/// The gRPC listener had no timeouts and no concurrency ceiling: a peer could
141/// open a connection, never finish a request, and hold server resources
142/// indefinitely, or open unbounded concurrent streams on one connection.
143/// Every `serve*` entry point builds through here so the three of them cannot
144/// drift apart.
145fn 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    /// Optional OAuth/OIDC JWT validator. When set, the gRPC
169    /// interceptor validates JWT-shaped bearers against the issuer's
170    /// JWKS *before* attempting `AuthStore` session/api-key lookups.
171    /// Build externally via `crate::auth::OAuthValidator::with_verifier`
172    /// and attach with [`Self::with_oauth_validator`].
173    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        // Create runtime first so we can access the pager for vault pages.
188        let runtime = RedDBRuntime::with_options(db_options.clone())?;
189
190        let auth_store = if db_options.auth.vault_enabled {
191            // The vault stores its encrypted state in reserved pages inside
192            // the main .rdb file.  Extract the pager reference from the
193            // runtime's underlying store.
194            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        // Inject the auth store into the runtime so that Value::Secret
215        // auto-encrypt/decrypt can read the vault AES key.
216        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    /// Attach an externally-constructed OAuth/OIDC JWT validator. Once
227    /// set, JWT-shaped bearer tokens (3-segment) on the
228    /// `authorization` metadata are validated against the issuer's
229    /// JWKS, expiry, audience, etc. Non-JWT bearers fall back to the
230    /// `AuthStore` session/API-key path.
231    pub fn with_oauth_validator(mut self, validator: Arc<crate::auth::OAuthValidator>) -> Self {
232        self.oauth_validator = Some(validator);
233        self
234    }
235
236    /// Inspect the active OAuth validator, when one is configured.
237    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            // Constant-time SHA256 fingerprint logged for ops triage —
272            // never the bytes of cert/key themselves.
273            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    /// Serve gRPC over a stream of already-accepted connections fed by the
303    /// in-process protocol demux (issue #933). The demux classifies each
304    /// inbound connection on the shared port and hands the HTTP/2 ones
305    /// straight in through `rx` — there is no loopback socket and no
306    /// `copy_bidirectional` hop. The server runs until `rx` closes (the
307    /// demux acceptor dropped its sender) and all in-flight RPCs drain.
308    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    /// Largest gRPC message accepted on the way in or produced on the way
327    /// out. Bulk inserts ride on this, so it is generous, but it is no longer
328    /// 256 MiB: that let one request pin a quarter gigabyte of decoded
329    /// protobuf per stream before any handler ran. Override with
330    /// `REDDB_GRPC_MAX_MESSAGE_BYTES`; a malformed value keeps the default.
331    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        // Advertise zstd + gzip so clients can opt in. Server compresses
342        // outbound replies with zstd; sticking to a single send codec keeps
343        // CPU predictable while still accepting either on inbound.
344        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/OIDC JWT validator built once from `auth_store.config().oauth`
362    /// when the operator enables OAuth. `None` means JWT bearers fall
363    /// back to the AuthStore lookup path.
364    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
425/// RAII guard scoping the runtime's thread-local identity to one gRPC SQL
426/// call. The catalog interceptor resolves the principal (`tenant/user` or
427/// `user`) but the runtime gates read a thread-local; without installing it
428/// every `Query`/`BatchQuery`/`ExecutePrepared` ran with enforcement off.
429/// The executor is synchronous, so the thread-local holds for the call.
430struct 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
480/// Resolve the `(UserId, Role)` a gRPC request executes as from the typed
481/// dispatch context installed by the catalog interceptor.
482fn 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        // Client-shaped request errors stay INVALID_ARGUMENT: seam bind
598        // failures and commit-policy floor violations.
599        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        // Everything else keeps the pre-migration status surface: to_status
607        // routes the ask_primary_sync_unavailable prefix to UNAVAILABLE and
608        // generic Query errors to INTERNAL.
609        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    #[tokio::test]
619    async fn grpc_execution_refuses_when_shared_runtime_queue_is_full() {
620        let runtime =
621            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
622        runtime.configure_execution_admission(1, 0, 100);
623        let admission = runtime.execution_admission();
624        let active = admission.acquire().await.expect("active slot");
625
626        let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
627        assert_eq!(status.code(), tonic::Code::ResourceExhausted);
628        assert!(status.message().contains("execution_queue_full"));
629        drop(active);
630    }
631
632    #[tokio::test]
633    async fn grpc_execution_reports_queue_deadline_separately() {
634        let runtime =
635            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
636        runtime.configure_execution_admission(1, 1, 1);
637        let admission = runtime.execution_admission();
638        let active = admission.acquire().await.expect("active slot");
639
640        let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
641        assert_eq!(status.code(), tonic::Code::DeadlineExceeded);
642        assert!(status.message().contains("execution_queue_timeout"));
643        drop(active);
644    }
645
646    #[test]
647    fn grpc_query_value_maps_to_request_param_variants() {
648        let cases = vec![
649            (
650                QueryValue {
651                    kind: Some(Kind::NullValue(proto::QueryNull {})),
652                },
653                ParamValue::Null,
654            ),
655            (
656                QueryValue {
657                    kind: Some(Kind::BoolValue(true)),
658                },
659                ParamValue::Bool(true),
660            ),
661            (
662                QueryValue {
663                    kind: Some(Kind::IntValue(42)),
664                },
665                ParamValue::Int64(42),
666            ),
667            (
668                QueryValue {
669                    kind: Some(Kind::FloatValue(1.5)),
670                },
671                ParamValue::Float64(1.5),
672            ),
673            (
674                QueryValue {
675                    kind: Some(Kind::BytesValue(vec![0, 1, 2])),
676                },
677                ParamValue::Bytes(vec![0, 1, 2]),
678            ),
679            (
680                QueryValue {
681                    kind: Some(Kind::VectorValue(proto::QueryVector {
682                        values: vec![0.25, 0.5],
683                    })),
684                },
685                ParamValue::Vector(vec![0.25, 0.5]),
686            ),
687            (
688                QueryValue {
689                    kind: Some(Kind::TimestampValue(1_779_999_000)),
690                },
691                ParamValue::Timestamp(1_779_999_000),
692            ),
693            (
694                QueryValue {
695                    kind: Some(Kind::UuidValue(vec![0x11; 16])),
696                },
697                ParamValue::Uuid([0x11; 16]),
698            ),
699        ];
700
701        for (input, expected) in cases {
702            assert_eq!(grpc_query_value_to_param_value(input).unwrap(), expected);
703        }
704
705        assert_eq!(
706            grpc_query_value_to_param_value(QueryValue {
707                kind: Some(Kind::TextValue("alice".into())),
708            })
709            .unwrap(),
710            ParamValue::Text("alice".to_string())
711        );
712        assert_eq!(
713            grpc_query_value_to_param_value(QueryValue {
714                kind: Some(Kind::JsonValue("{\"role\":\"admin\"}".into())),
715            })
716            .unwrap(),
717            ParamValue::Json(b"{\"role\":\"admin\"}".to_vec())
718        );
719    }
720
721    #[test]
722    fn grpc_query_value_rejects_missing_kind_and_bad_uuid() {
723        assert!(grpc_query_value_to_param_value(QueryValue { kind: None }).is_err());
724        assert!(grpc_query_value_to_param_value(QueryValue {
725            kind: Some(Kind::UuidValue(vec![0; 15])),
726        })
727        .is_err());
728    }
729
730    #[test]
731    fn grpc_query_rejects_empty_query_before_runtime_parse() {
732        let runtime =
733            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
734        let prepared = PreparedRegistry::new();
735        let err = execute_grpc_query_request(
736            &runtime,
737            &prepared,
738            None,
739            "  ".to_string(),
740            Vec::new(),
741            None,
742        )
743        .expect_err("empty query should fail");
744
745        assert_eq!(err.code(), tonic::Code::InvalidArgument);
746        assert_eq!(err.message(), "query field cannot be empty");
747    }
748
749    #[test]
750    fn grpc_query_params_are_bound_before_execution() {
751        let runtime =
752            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
753        seed_grpc_param_table(&runtime);
754        let prepared = PreparedRegistry::new();
755
756        let result = execute_grpc_query_request(
757            &runtime,
758            &prepared,
759            None,
760            "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
761            grpc_param_values(),
762            None,
763        )
764        .expect("parameterized query");
765
766        assert_eq!(result.result.records.len(), 1);
767    }
768
769    #[test]
770    fn grpc_query_enforces_ack_n_commit_policy_fail_closed() {
771        let _env_lock = env_lock().lock().expect("env lock");
772        let _env = EnvGuard::set(&[
773            ("RED_PRIMARY_COMMIT_POLICY", "ack_n=1"),
774            ("RED_REPLICATION_ACK_TIMEOUT_MS", "20"),
775            ("RED_COMMIT_FAIL_ON_TIMEOUT", "false"),
776        ]);
777        let data_path = temp_data_path("grpc_ack_n_timeout");
778        cleanup(&data_path);
779
780        let runtime = RedDBRuntime::with_options(
781            crate::api::RedDBOptions::persistent(&data_path)
782                .with_replication(crate::replication::ReplicationConfig::primary()),
783        )
784        .expect("runtime");
785        let prepared = PreparedRegistry::new();
786
787        let err = execute_grpc_query_request(
788            &runtime,
789            &prepared,
790            None,
791            "INSERT INTO grpc_ack_items (id, name) VALUES (1, 'alpha')".to_string(),
792            Vec::new(),
793            None,
794        )
795        .expect_err("ack_n without replica ack must fail closed");
796
797        assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
798        assert!(
799            err.message().contains("commit policy timed out")
800                && err.message().contains("local commit is durable"),
801            "error should identify commit policy timeout, got {err:?}"
802        );
803        assert!(
804            runtime.cdc_current_lsn() > 0,
805            "local mutation should advance CDC before gRPC response fails"
806        );
807
808        cleanup(&data_path);
809    }
810
811    #[tokio::test]
812    async fn grpc_query_rpc_binds_query_request_params() {
813        let runtime =
814            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
815        seed_grpc_param_table(&runtime);
816        let service = GrpcRuntime {
817            runtime,
818            auth_store: Arc::new(AuthStore::new(crate::auth::AuthConfig::default())),
819            prepared_registry: Arc::new(PreparedRegistry::new()),
820            oauth_validator: None,
821        };
822
823        let reply = RedDb::query(
824            &service,
825            Request::new(QueryRequest {
826                query: "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
827                entity_types: Vec::new(),
828                capabilities: Vec::new(),
829                params: grpc_param_values(),
830            }),
831        )
832        .await
833        .expect("query rpc")
834        .into_inner();
835
836        assert_eq!(reply.record_count, 1);
837        assert!(reply.result_json.contains("Alice"), "{}", reply.result_json);
838        assert!(!reply.result_json.contains("Bob"), "{}", reply.result_json);
839    }
840
841    #[tokio::test]
842    async fn grpc_prepared_id_is_shared_by_connection_listeners_only() {
843        let runtime =
844            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
845        let server = RedDBGrpcServer::new(runtime.clone());
846        let plain_listener = server.grpc_runtime();
847        let tls_listener = server.grpc_runtime();
848        let second_connection = RedDBGrpcServer::new(runtime).grpc_runtime();
849
850        let prepared = RedDb::prepare_query(
851            &plain_listener,
852            Request::new(PrepareQueryRequest {
853                query: "SELECT 7 AS value".to_string(),
854            }),
855        )
856        .await
857        .expect("prepare on plaintext listener")
858        .into_inner();
859
860        let reply = RedDb::execute_prepared(
861            &tls_listener,
862            Request::new(ExecutePreparedRequest {
863                prepared_id: prepared.prepared_id,
864                bind_json: vec!["42".to_string()],
865            }),
866        )
867        .await
868        .expect("execute on TLS listener of the same connection")
869        .into_inner();
870        assert!(reply.result_json.contains("42"), "{}", reply.result_json);
871
872        let error = RedDb::execute_prepared(
873            &second_connection,
874            Request::new(ExecutePreparedRequest {
875                prepared_id: prepared.prepared_id,
876                bind_json: vec!["42".to_string()],
877            }),
878        )
879        .await
880        .expect_err("another connection must not resolve the prepared ID");
881        assert_eq!(error.code(), tonic::Code::NotFound);
882    }
883
884    #[tokio::test]
885    async fn grpc_execute_prepared_rejects_a_stale_ddl_epoch() {
886        let runtime =
887            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
888        let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
889        let prepared = RedDb::prepare_query(
890            &service,
891            Request::new(PrepareQueryRequest {
892                query: "SELECT 7 AS value".to_string(),
893            }),
894        )
895        .await
896        .expect("prepare query")
897        .into_inner();
898
899        runtime
900            .execute_query("CREATE TABLE grpc_prepared_epoch (id INTEGER)")
901            .expect("execute DDL");
902
903        let error = RedDb::execute_prepared(
904            &service,
905            Request::new(ExecutePreparedRequest {
906                prepared_id: prepared.prepared_id,
907                bind_json: vec!["42".to_string()],
908            }),
909        )
910        .await
911        .expect_err("DDL must invalidate the gRPC prepared shape");
912        assert_eq!(error.code(), tonic::Code::FailedPrecondition);
913        assert_eq!(error.message(), "prepared_needs_replan");
914    }
915
916    #[tokio::test]
917    async fn grpc_prepared_kill_switch_rejects_prepare_and_execute() {
918        let runtime =
919            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
920        let service = RedDBGrpcServer::new(runtime).grpc_runtime();
921        let prepared = RedDb::prepare_query(
922            &service,
923            Request::new(PrepareQueryRequest {
924                query: "SELECT 7 AS value".to_string(),
925            }),
926        )
927        .await
928        .expect("prepare before kill switch")
929        .into_inner();
930
931        service.prepared_registry.disable();
932
933        let prepare_error = RedDb::prepare_query(
934            &service,
935            Request::new(PrepareQueryRequest {
936                query: "SELECT 1".to_string(),
937            }),
938        )
939        .await
940        .expect_err("kill switch must reject prepare");
941        assert_eq!(prepare_error.code(), tonic::Code::FailedPrecondition);
942        assert_eq!(prepare_error.message(), "prepared statements disabled");
943
944        let execute_error = RedDb::execute_prepared(
945            &service,
946            Request::new(ExecutePreparedRequest {
947                prepared_id: prepared.prepared_id,
948                bind_json: vec!["42".to_string()],
949            }),
950        )
951        .await
952        .expect_err("kill switch must reject execute");
953        assert_eq!(execute_error.code(), tonic::Code::FailedPrecondition);
954        assert_eq!(execute_error.message(), "prepared statements disabled");
955    }
956
957    #[tokio::test]
958    async fn grpc_query_honors_per_request_commit_policy() {
959        let _env_lock = env_lock().lock().expect("env lock");
960        let _env = EnvGuard::set(&[("RED_PRIMARY_COMMIT_POLICY", "ack_n=1")]);
961        let runtime = RedDBRuntime::with_options(
962            crate::api::RedDBOptions::in_memory()
963                .with_replication(crate::replication::ReplicationConfig::primary()),
964        )
965        .expect("runtime");
966        runtime
967            .execute_query("CREATE TABLE grpc_request_policy (id INTEGER)")
968            .expect("create table");
969        let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
970        let mut request = Request::new(QueryRequest {
971            query: "INSERT INTO grpc_request_policy (id) VALUES (1)".to_string(),
972            entity_types: Vec::new(),
973            capabilities: Vec::new(),
974            params: Vec::new(),
975        });
976        request
977            .metadata_mut()
978            .insert("x-reddb-commit-policy", "local".parse().expect("metadata"));
979
980        let error = RedDb::query(&service, request)
981            .await
982            .expect_err("weaker per-request policy must fail before mutation");
983        assert_eq!(error.code(), tonic::Code::InvalidArgument);
984        assert!(error.message().contains("weaker than resolved floor"));
985
986        let rows = runtime
987            .execute_query("SELECT id FROM grpc_request_policy")
988            .expect("read rows");
989        assert!(rows.result.records.is_empty());
990    }
991
992    #[tokio::test]
993    async fn pull_wal_records_rejects_stale_term_on_grpc_path() {
994        let runtime = RedDBRuntime::with_options(
995            crate::api::RedDBOptions::in_memory()
996                .with_replication(crate::replication::ReplicationConfig::primary().with_term(6)),
997        )
998        .expect("runtime");
999        let auth_store = Arc::new(AuthStore::new(crate::auth::AuthConfig {
1000            enabled: true,
1001            require_auth: true,
1002            ..crate::auth::AuthConfig::default()
1003        }));
1004        let bootstrap = auth_store
1005            .bootstrap("replica", "secret")
1006            .expect("bootstrap");
1007        let policy = crate::auth::policies::Policy::from_json_str(
1008            r#"{
1009                "id": "replication-stream",
1010                "version": 1,
1011                "statements": [{
1012                    "effect": "allow",
1013                    "actions": ["cluster:replication:stream"],
1014                    "resources": ["cluster:replication"]
1015                }]
1016            }"#,
1017        )
1018        .expect("policy");
1019        auth_store.put_policy(policy).expect("install policy");
1020        auth_store
1021            .attach_policy(
1022                crate::auth::store::PrincipalRef::User(crate::auth::UserId::platform("replica")),
1023                "replication-stream",
1024            )
1025            .expect("attach policy");
1026        let service = GrpcRuntime {
1027            runtime,
1028            auth_store,
1029            prepared_registry: Arc::new(PreparedRegistry::new()),
1030            oauth_validator: None,
1031        };
1032
1033        let open = reddb_wire::replication::WalStreamOpen {
1034            since_lsn: 0,
1035            max_count: 1,
1036            replica_id: Some("replica-a".to_string()),
1037            term: 5,
1038            await_data: false,
1039            await_timeout_ms: 1,
1040        };
1041        let mut request = Request::new(JsonPayloadRequest {
1042            payload_json: String::from_utf8(open.encode_json()).expect("json"),
1043        });
1044        request.metadata_mut().insert(
1045            "authorization",
1046            format!("Bearer {}", bootstrap.api_key.key)
1047                .parse()
1048                .expect("metadata"),
1049        );
1050
1051        let err = RedDb::pull_wal_records(&service, request)
1052            .await
1053            .expect_err("stale term should be fenced");
1054
1055        assert_eq!(err.code(), tonic::Code::FailedPrecondition);
1056        assert!(
1057            err.message().contains("stale")
1058                || err.message().contains("fenced")
1059                || err.message().contains("current term"),
1060            "unexpected stale-term error: {err:?}"
1061        );
1062    }
1063
1064    fn seed_grpc_param_table(runtime: &RedDBRuntime) {
1065        runtime
1066            .execute_query("CREATE TABLE p (id INTEGER, name TEXT)")
1067            .expect("create table");
1068        runtime
1069            .execute_query("INSERT INTO p (id, name) VALUES (1, 'Alice')")
1070            .expect("insert alice");
1071        runtime
1072            .execute_query("INSERT INTO p (id, name) VALUES (2, 'Bob')")
1073            .expect("insert bob");
1074    }
1075
1076    fn grpc_param_values() -> Vec<QueryValue> {
1077        vec![
1078            QueryValue {
1079                kind: Some(Kind::IntValue(1)),
1080            },
1081            QueryValue {
1082                kind: Some(Kind::TextValue("Alice".to_string())),
1083            },
1084        ]
1085    }
1086
1087    fn env_lock() -> &'static std::sync::Mutex<()> {
1088        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1089        LOCK.get_or_init(|| std::sync::Mutex::new(()))
1090    }
1091
1092    struct EnvGuard {
1093        previous: Vec<(&'static str, Option<String>)>,
1094    }
1095
1096    impl EnvGuard {
1097        fn set(vars: &[(&'static str, &'static str)]) -> Self {
1098            let previous = vars
1099                .iter()
1100                .map(|(key, _)| (*key, std::env::var(key).ok()))
1101                .collect();
1102            for (key, value) in vars {
1103                std::env::set_var(key, value);
1104            }
1105            Self { previous }
1106        }
1107    }
1108
1109    impl Drop for EnvGuard {
1110        fn drop(&mut self) {
1111            for (key, value) in self.previous.iter().rev() {
1112                match value {
1113                    Some(value) => std::env::set_var(key, value),
1114                    None => std::env::remove_var(key),
1115                }
1116            }
1117        }
1118    }
1119
1120    fn temp_data_path(name: &str) -> std::path::PathBuf {
1121        let suffix = SystemTime::now()
1122            .duration_since(UNIX_EPOCH)
1123            .unwrap()
1124            .as_nanos();
1125        std::env::temp_dir().join(format!("reddb_{name}_{suffix}.rdb"))
1126    }
1127
1128    fn cleanup(data_path: &std::path::Path) {
1129        let _ = std::fs::remove_file(data_path);
1130        let _ = std::fs::remove_file(
1131            crate::replication::primary::PrimaryReplication::slot_path_for(data_path),
1132        );
1133        let _ = std::fs::remove_file(crate::replication::primary::LogicalWalSpool::path_for(
1134            data_path,
1135        ));
1136        let _ = std::fs::remove_dir_all(
1137            crate::replication::primary::PrimaryReplication::primary_replica_root_for(data_path),
1138        );
1139        reddb_file::cleanup_rebootstrap_artifacts(data_path);
1140    }
1141}
1142
1143#[cfg(test)]
1144mod grpc_ask_query_reply_tests {
1145    use super::*;
1146    use crate::storage::query::unified::{UnifiedRecord, UnifiedResult};
1147    use reddb_rql::modes::QueryMode;
1148    use reddb_types::Value as SchemaValue;
1149
1150    fn ask_runtime_result() -> RuntimeQueryResult {
1151        let mut result = UnifiedResult::with_columns(vec![
1152            "answer".into(),
1153            "provider".into(),
1154            "model".into(),
1155            "mode".into(),
1156            "retry_count".into(),
1157            "prompt_tokens".into(),
1158            "completion_tokens".into(),
1159            "sources_flat".into(),
1160            "citations".into(),
1161            "validation".into(),
1162        ]);
1163        let mut record = UnifiedRecord::new();
1164        record.set("answer", SchemaValue::text("Deploy failed [^1]."));
1165        record.set("provider", SchemaValue::text("openai"));
1166        record.set("model", SchemaValue::text("gpt-4o-mini"));
1167        record.set("mode", SchemaValue::text("strict"));
1168        record.set("retry_count", SchemaValue::Integer(0));
1169        record.set("prompt_tokens", SchemaValue::Integer(11));
1170        record.set("completion_tokens", SchemaValue::Integer(7));
1171        record.set(
1172            "sources_flat",
1173            SchemaValue::Json(
1174                br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
1175            ),
1176        );
1177        record.set(
1178            "citations",
1179            SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
1180        );
1181        record.set(
1182            "validation",
1183            SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
1184        );
1185        result.push(record);
1186
1187        RuntimeQueryResult {
1188            query: "ASK 'why did deploy fail?'".to_string(),
1189            mode: QueryMode::Sql,
1190            statement: "ask",
1191            engine: "runtime-ai",
1192            result,
1193            affected_rows: 0,
1194            statement_type: "select",
1195            bookmark: None,
1196            notice: None,
1197        }
1198    }
1199
1200    #[test]
1201    fn query_reply_ask_result_json_uses_full_canonical_schema() {
1202        let reply = query_reply(ask_runtime_result(), &None, &None);
1203        let json: crate::json::Value =
1204            crate::json::from_str(&reply.result_json).expect("valid ask json");
1205
1206        assert_eq!(
1207            json.get("answer").and_then(crate::json::Value::as_str),
1208            Some("Deploy failed [^1].")
1209        );
1210        assert_eq!(
1211            json.get("cache_hit").and_then(crate::json::Value::as_bool),
1212            Some(false)
1213        );
1214        assert_eq!(
1215            json.get("cost_usd").and_then(crate::json::Value::as_f64),
1216            Some(0.0)
1217        );
1218        assert_eq!(
1219            json.get("mode").and_then(crate::json::Value::as_str),
1220            Some("strict")
1221        );
1222        assert_eq!(
1223            json.get("retry_count").and_then(crate::json::Value::as_u64),
1224            Some(0)
1225        );
1226        assert!(
1227            json.get("records").is_none(),
1228            "ASK must not be row-wrapped: {}",
1229            reply.result_json
1230        );
1231        assert!(
1232            json.get("sources_flat")
1233                .and_then(crate::json::Value::as_array)
1234                .is_some_and(|sources| sources.len() == 1
1235                    && sources[0]
1236                        .get("payload")
1237                        .and_then(crate::json::Value::as_str)
1238                        .is_some()),
1239            "sources_flat must be parsed with payload fallback: {}",
1240            reply.result_json
1241        );
1242        assert!(
1243            json.get("citations")
1244                .and_then(crate::json::Value::as_array)
1245                .is_some_and(|citations| citations.len() == 1),
1246            "citations must be parsed: {}",
1247            reply.result_json
1248        );
1249        assert_eq!(
1250            json.get("validation")
1251                .and_then(|v| v.get("ok"))
1252                .and_then(crate::json::Value::as_bool),
1253            Some(true)
1254        );
1255    }
1256
1257    #[test]
1258    fn query_reply_non_ask_answer_column_keeps_row_shape() {
1259        let mut result = UnifiedResult::with_columns(vec!["answer".into()]);
1260        let mut record = UnifiedRecord::new();
1261        record.set("answer", SchemaValue::text("plain select"));
1262        result.push(record);
1263
1264        let reply = query_reply(
1265            RuntimeQueryResult {
1266                query: "SELECT 'plain select' AS answer".to_string(),
1267                mode: QueryMode::Sql,
1268                statement: "select",
1269                engine: "runtime-sql",
1270                result,
1271                affected_rows: 0,
1272                statement_type: "select",
1273                bookmark: None,
1274                notice: None,
1275            },
1276            &None,
1277            &None,
1278        );
1279        let json: crate::json::Value =
1280            crate::json::from_str(&reply.result_json).expect("valid query json");
1281
1282        assert!(
1283            json.get("records").is_some(),
1284            "non-ASK must stay row-wrapped"
1285        );
1286        assert!(
1287            json.get("answer").is_none(),
1288            "non-ASK must not use ASK envelope"
1289        );
1290    }
1291
1292    #[test]
1293    fn query_reply_non_ask_json_column_preserves_object() {
1294        let mut result = UnifiedResult::with_columns(vec!["value".into()]);
1295        let mut record = UnifiedRecord::new();
1296        record.set(
1297            "value",
1298            SchemaValue::Json(br#"{"alpha":"A","nested":{"leaf":12}}"#.to_vec()),
1299        );
1300        result.push(record);
1301
1302        let reply = query_reply(
1303            RuntimeQueryResult {
1304                query: "LIST KV proj AS JSON".to_string(),
1305                mode: QueryMode::Sql,
1306                statement: "kv_list_json",
1307                engine: "kv",
1308                result,
1309                affected_rows: 0,
1310                statement_type: "select",
1311                bookmark: None,
1312                notice: None,
1313            },
1314            &None,
1315            &None,
1316        );
1317        let json: crate::json::Value =
1318            crate::json::from_str(&reply.result_json).expect("valid query json");
1319        let value = json
1320            .get("records")
1321            .and_then(crate::json::Value::as_array)
1322            .and_then(|records| records.first())
1323            .and_then(|record| record.get("value"))
1324            .expect("value column");
1325
1326        assert_eq!(
1327            value.get("alpha").and_then(crate::json::Value::as_str),
1328            Some("A")
1329        );
1330        assert_eq!(
1331            value
1332                .get("nested")
1333                .and_then(|nested| nested.get("leaf"))
1334                .and_then(crate::json::Value::as_f64),
1335            Some(12.0)
1336        );
1337    }
1338}
1339
1340/// Emit a single info-level event with the SHA-256 fingerprint of the
1341/// active gRPC server cert + an mTLS flag. Never logs PEM bytes.
1342fn log_grpc_tls_identity(tls: &GrpcTlsOptions) {
1343    use sha2::{Digest, Sha256};
1344    let cert_fp = {
1345        let mut h = Sha256::new();
1346        h.update(&tls.cert_pem);
1347        let digest = h.finalize();
1348        // First 16 hex chars are enough for human cross-check; the full
1349        // SHA-256 lives in audit logs only.
1350        let mut buf = String::with_capacity(64);
1351        for b in digest.iter() {
1352            buf.push_str(&format!("{b:02x}"));
1353        }
1354        buf
1355    };
1356    tracing::info!(
1357        target: "reddb::security",
1358        transport = "grpc",
1359        cert_sha256 = %cert_fp,
1360        mtls = tls.client_ca_pem.is_some(),
1361        "gRPC TLS identity loaded"
1362    );
1363}
1364
1365include!("grpc/service_impl.rs");