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 reddb_types::Value;
44use tokio_stream::wrappers::TcpListenerStream;
45use tonic::metadata::MetadataMap;
46use tonic::{Request, Response, Status};
47
48// gRPC protobuf types and tonic stubs live in the standalone
49// `reddb-grpc-proto` crate so `reddb-server` and `reddb-client`
50// can both consume them without a dependency cycle. We expose
51// them under the legacy `proto` module path so existing
52// `crate::grpc::proto::…` imports keep resolving.
53pub use reddb_grpc_proto as proto;
54
55use proto::red_db_server::{RedDb, RedDbServer};
56use proto::{
57    ask_stream_event, AskAnswerToken, AskReply, AskRequest, AskSources, AskStreamEvent,
58    BatchInsertChunk, BatchInsertReply, BatchQueryReply, BatchQueryRequest, BulkEntityReply,
59    Citation, CollectionRequest, CollectionsReply, DeleteEntityRequest, DeploymentProfileRequest,
60    Empty, EntityReply, ExecutePreparedRequest, ExportRequest, GraphProjectionUpsertRequest,
61    HealthReply, IndexNameRequest, IndexToggleRequest, JsonBulkCreateRequest, JsonCreateRequest,
62    JsonPayloadRequest, KvWatchEvent, KvWatchRequest, ManifestRequest, OperationReply,
63    PayloadReply, PrepareQueryReply, PrepareQueryRequest, QueryReply, QueryRequest, QueryValue,
64    ScanEntity, ScanReply, ScanRequest, StatsReply, TopologyReply, TopologyRequest,
65    UpdateEntityRequest, Validation, ValidationItem,
66};
67
68pub(crate) mod catalog_dispatch;
69pub(crate) mod control_support;
70mod entity_ops;
71mod input_support;
72pub(crate) mod scan_json;
73
74use self::catalog_dispatch::*;
75use self::control_support::*;
76use self::entity_ops::*;
77use self::input_support::*;
78use self::scan_json::*;
79
80#[derive(Debug, Clone)]
81pub struct GrpcServerOptions {
82    pub bind_addr: String,
83    /// Optional TLS configuration. When set the server terminates
84    /// TLS for inbound gRPC traffic via `tonic::transport::ServerTlsConfig`.
85    /// When `None`, the listener stays plaintext (back-compat for
86    /// loopback / sidecar deployments where a sidecar terminates TLS).
87    pub tls: Option<GrpcTlsOptions>,
88}
89
90/// PEM-encoded TLS material for gRPC's tonic-rustls server.
91///
92/// The server identity is required (cert + key); the optional
93/// client-CA enables mTLS — when present, tonic verifies and
94/// requires a client cert chain that anchors at this CA bundle.
95#[derive(Debug, Clone)]
96pub struct GrpcTlsOptions {
97    /// PEM bytes for the server certificate chain (leaf first).
98    pub cert_pem: Vec<u8>,
99    /// PEM bytes for the server private key (PKCS#8 / SEC1 / RSA).
100    pub key_pem: Vec<u8>,
101    /// Optional PEM bytes for the trust anchor used to verify
102    /// client certificates. When `Some(_)`, the server requires
103    /// every client to present a cert that chains to this CA;
104    /// when `None`, the server runs one-way TLS only.
105    pub client_ca_pem: Option<Vec<u8>>,
106}
107
108impl GrpcTlsOptions {
109    /// Build a `tonic` `ServerTlsConfig` from PEM bytes, applying
110    /// rustls defaults (TLS 1.2 + 1.3 — older versions are not
111    /// negotiable on tokio-rustls 0.26).
112    pub fn to_tonic_config(
113        &self,
114    ) -> Result<tonic::transport::ServerTlsConfig, Box<dyn std::error::Error>> {
115        let identity = tonic::transport::Identity::from_pem(&self.cert_pem, &self.key_pem);
116        let mut cfg = tonic::transport::ServerTlsConfig::new().identity(identity);
117        if let Some(ca_pem) = &self.client_ca_pem {
118            cfg = cfg.client_ca_root(tonic::transport::Certificate::from_pem(ca_pem));
119        }
120        Ok(cfg)
121    }
122}
123
124/// Per-connection ceiling on concurrent HTTP/2 streams. tonic's default is
125/// unbounded, so one connection could open arbitrarily many RPCs.
126const GRPC_MAX_CONCURRENT_STREAMS: u32 = 256;
127
128/// Ceiling on concurrent in-flight requests per connection, mirroring the
129/// HTTP edge's in-flight cap.
130const GRPC_CONCURRENCY_LIMIT_PER_CONNECTION: usize = 256;
131
132/// Keepalive ping cadence and reply grace. Without these a half-open
133/// connection is only reclaimed when the OS notices.
134const GRPC_KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
135const GRPC_KEEPALIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
136
137/// The gRPC listener had no timeouts and no concurrency ceiling: a peer could
138/// open a connection, never finish a request, and hold server resources
139/// indefinitely, or open unbounded concurrent streams on one connection.
140/// Every `serve*` entry point builds through here so the three of them cannot
141/// drift apart.
142fn configured_transport_builder() -> tonic::transport::Server {
143    tonic::transport::Server::builder()
144        .max_concurrent_streams(Some(GRPC_MAX_CONCURRENT_STREAMS))
145        .concurrency_limit_per_connection(GRPC_CONCURRENCY_LIMIT_PER_CONNECTION)
146        .http2_keepalive_interval(Some(GRPC_KEEPALIVE_INTERVAL))
147        .http2_keepalive_timeout(Some(GRPC_KEEPALIVE_TIMEOUT))
148}
149
150impl Default for GrpcServerOptions {
151    fn default() -> Self {
152        Self {
153            bind_addr: "127.0.0.1:55055".to_string(),
154            tls: None,
155        }
156    }
157}
158
159#[derive(Clone)]
160pub struct RedDBGrpcServer {
161    runtime: RedDBRuntime,
162    options: GrpcServerOptions,
163    auth_store: Arc<AuthStore>,
164    prepared_registry: Arc<PreparedRegistry>,
165    /// Optional OAuth/OIDC JWT validator. When set, the gRPC
166    /// interceptor validates JWT-shaped bearers against the issuer's
167    /// JWKS *before* attempting `AuthStore` session/api-key lookups.
168    /// Build externally via `crate::auth::OAuthValidator::with_verifier`
169    /// and attach with [`Self::with_oauth_validator`].
170    oauth_validator: Option<Arc<crate::auth::OAuthValidator>>,
171}
172
173impl RedDBGrpcServer {
174    pub fn new(runtime: RedDBRuntime) -> Self {
175        let auth_config = crate::auth::AuthConfig::default();
176        let auth_store = Arc::new(AuthStore::new(auth_config));
177        Self::with_options(runtime, GrpcServerOptions::default(), auth_store)
178    }
179
180    pub fn from_database_options(
181        db_options: RedDBOptions,
182        options: GrpcServerOptions,
183    ) -> RedDBResult<Self> {
184        // Create runtime first so we can access the pager for vault pages.
185        let runtime = RedDBRuntime::with_options(db_options.clone())?;
186
187        let auth_store = if db_options.auth.vault_enabled {
188            // The vault stores its encrypted state in reserved pages inside
189            // the main .rdb file.  Extract the pager reference from the
190            // runtime's underlying store.
191            let pager = runtime.db().store().pager().cloned().ok_or_else(|| {
192                crate::api::RedDBError::Internal(
193                    "vault requires a paged database (persistent mode)".into(),
194                )
195            })?;
196            let store = AuthStore::with_vault(db_options.auth.clone(), pager)
197                .map_err(|e| crate::api::RedDBError::Internal(e.to_string()))?;
198            Arc::new(store)
199        } else {
200            Arc::new(AuthStore::new(db_options.auth.clone()))
201        };
202        auth_store.bootstrap_from_env();
203        Ok(Self::with_options(runtime, options, auth_store))
204    }
205
206    pub fn with_options(
207        runtime: RedDBRuntime,
208        options: GrpcServerOptions,
209        auth_store: Arc<AuthStore>,
210    ) -> Self {
211        // Inject the auth store into the runtime so that Value::Secret
212        // auto-encrypt/decrypt can read the vault AES key.
213        runtime.set_auth_store(Arc::clone(&auth_store));
214        Self {
215            runtime,
216            options,
217            auth_store,
218            prepared_registry: Arc::new(PreparedRegistry::new()),
219            oauth_validator: None,
220        }
221    }
222
223    /// Attach an externally-constructed OAuth/OIDC JWT validator. Once
224    /// set, JWT-shaped bearer tokens (3-segment) on the
225    /// `authorization` metadata are validated against the issuer's
226    /// JWKS, expiry, audience, etc. Non-JWT bearers fall back to the
227    /// `AuthStore` session/API-key path.
228    pub fn with_oauth_validator(mut self, validator: Arc<crate::auth::OAuthValidator>) -> Self {
229        self.oauth_validator = Some(validator);
230        self
231    }
232
233    /// Inspect the active OAuth validator, when one is configured.
234    pub fn oauth_validator(&self) -> Option<&Arc<crate::auth::OAuthValidator>> {
235        self.oauth_validator.as_ref()
236    }
237
238    pub fn runtime(&self) -> &RedDBRuntime {
239        &self.runtime
240    }
241
242    pub fn options(&self) -> &GrpcServerOptions {
243        &self.options
244    }
245
246    pub fn auth_store(&self) -> &Arc<AuthStore> {
247        &self.auth_store
248    }
249
250    pub(crate) fn with_listener_options(mut self, options: GrpcServerOptions) -> Self {
251        self.options = options;
252        self
253    }
254
255    fn grpc_runtime(&self) -> GrpcRuntime {
256        GrpcRuntime {
257            runtime: self.runtime.clone(),
258            auth_store: self.auth_store.clone(),
259            prepared_registry: Arc::clone(&self.prepared_registry),
260            oauth_validator: self.oauth_validator.clone(),
261        }
262    }
263
264    pub async fn serve(&self) -> Result<(), Box<dyn std::error::Error>> {
265        let addr = self.options.bind_addr.parse()?;
266        let mut builder = configured_transport_builder();
267        if let Some(tls) = &self.options.tls {
268            // Constant-time SHA256 fingerprint logged for ops triage —
269            // never the bytes of cert/key themselves.
270            log_grpc_tls_identity(tls);
271            builder = builder.tls_config(tls.to_tonic_config()?)?;
272        }
273        builder
274            .add_service(Self::configured_service(self.grpc_runtime()))
275            .serve(addr)
276            .await?;
277        Ok(())
278    }
279
280    pub async fn serve_on(
281        &self,
282        listener: std::net::TcpListener,
283    ) -> Result<(), Box<dyn std::error::Error>> {
284        listener.set_nonblocking(true)?;
285        let listener = tokio::net::TcpListener::from_std(listener)?;
286        let incoming = TcpListenerStream::new(listener);
287        let mut builder = configured_transport_builder();
288        if let Some(tls) = &self.options.tls {
289            log_grpc_tls_identity(tls);
290            builder = builder.tls_config(tls.to_tonic_config()?)?;
291        }
292        builder
293            .add_service(Self::configured_service(self.grpc_runtime()))
294            .serve_with_incoming(incoming)
295            .await?;
296        Ok(())
297    }
298
299    /// Serve gRPC over a stream of already-accepted connections fed by the
300    /// in-process protocol demux (issue #933). The demux classifies each
301    /// inbound connection on the shared port and hands the HTTP/2 ones
302    /// straight in through `rx` — there is no loopback socket and no
303    /// `copy_bidirectional` hop. The server runs until `rx` closes (the
304    /// demux acceptor dropped its sender) and all in-flight RPCs drain.
305    pub(crate) async fn serve_router_demux(
306        &self,
307        rx: tokio::sync::mpsc::Receiver<tokio::net::TcpStream>,
308    ) -> Result<(), Box<dyn std::error::Error>> {
309        use tokio_stream::StreamExt;
310        let incoming = tokio_stream::wrappers::ReceiverStream::new(rx).map(Ok::<_, std::io::Error>);
311        let mut builder = configured_transport_builder();
312        if let Some(tls) = &self.options.tls {
313            log_grpc_tls_identity(tls);
314            builder = builder.tls_config(tls.to_tonic_config()?)?;
315        }
316        builder
317            .add_service(Self::configured_service(self.grpc_runtime()))
318            .serve_with_incoming(incoming)
319            .await?;
320        Ok(())
321    }
322
323    /// Largest gRPC message accepted on the way in or produced on the way
324    /// out. Bulk inserts ride on this, so it is generous, but it is no longer
325    /// 256 MiB: that let one request pin a quarter gigabyte of decoded
326    /// protobuf per stream before any handler ran. Override with
327    /// `REDDB_GRPC_MAX_MESSAGE_BYTES`; a malformed value keeps the default.
328    fn max_message_bytes() -> usize {
329        const DEFAULT: usize = 32 * 1024 * 1024;
330        std::env::var("REDDB_GRPC_MAX_MESSAGE_BYTES")
331            .ok()
332            .and_then(|raw| raw.trim().parse::<usize>().ok())
333            .filter(|value| *value > 0)
334            .unwrap_or(DEFAULT)
335    }
336
337    fn configured_service(runtime: GrpcRuntime) -> GrpcCatalogService {
338        // Advertise zstd + gzip so clients can opt in. Server compresses
339        // outbound replies with zstd; sticking to a single send codec keeps
340        // CPU predictable while still accepting either on inbound.
341        use tonic::codec::CompressionEncoding;
342        let max_message = Self::max_message_bytes();
343        let service = RedDbServer::new(runtime.clone())
344            .max_decoding_message_size(max_message)
345            .max_encoding_message_size(max_message)
346            .accept_compressed(CompressionEncoding::Zstd)
347            .accept_compressed(CompressionEncoding::Gzip)
348            .send_compressed(CompressionEncoding::Zstd);
349        GrpcCatalogService::new(service, runtime)
350    }
351}
352
353#[derive(Clone)]
354struct GrpcRuntime {
355    runtime: RedDBRuntime,
356    auth_store: Arc<AuthStore>,
357    prepared_registry: Arc<PreparedRegistry>,
358    /// OAuth/OIDC JWT validator built once from `auth_store.config().oauth`
359    /// when the operator enables OAuth. `None` means JWT bearers fall
360    /// back to the AuthStore lookup path.
361    oauth_validator: Option<Arc<crate::auth::OAuthValidator>>,
362}
363
364#[derive(Clone)]
365struct ResolvedGrpcAuth {
366    result: AuthResult,
367    identity: Option<(crate::auth::UserId, Role)>,
368}
369
370impl GrpcRuntime {
371    fn admin_use_cases(&self) -> AdminUseCases<'_, RedDBRuntime> {
372        AdminUseCases::new(&self.runtime)
373    }
374
375    fn entity_use_cases(&self) -> EntityUseCases<'_, RedDBRuntime> {
376        EntityUseCases::new(&self.runtime)
377    }
378
379    fn graph_use_cases(&self) -> GraphUseCases<'_, RedDBRuntime> {
380        GraphUseCases::new(&self.runtime)
381    }
382
383    fn native_use_cases(&self) -> NativeUseCases<'_, RedDBRuntime> {
384        NativeUseCases::new(&self.runtime)
385    }
386}
387
388fn grpc_query_value_to_param_value(value: QueryValue) -> Result<ParamValue, Status> {
389    use proto::query_value::Kind;
390
391    match value
392        .kind
393        .ok_or_else(|| Status::invalid_argument("missing query param value"))?
394    {
395        Kind::NullValue(_) => Ok(ParamValue::Null),
396        Kind::BoolValue(value) => Ok(ParamValue::Bool(value)),
397        Kind::IntValue(value) => Ok(ParamValue::Int64(value)),
398        Kind::FloatValue(value) => Ok(ParamValue::Float64(value)),
399        Kind::TextValue(value) => Ok(ParamValue::Text(value)),
400        Kind::BytesValue(value) => Ok(ParamValue::Bytes(value)),
401        Kind::VectorValue(value) => Ok(ParamValue::Vector(value.values)),
402        Kind::JsonValue(value) => {
403            let parsed = json_from_str::<JsonValue>(&value)
404                .map_err(|e| Status::invalid_argument(format!("json param parse error: {e}")))?;
405            let encoded = json_to_string(&parsed)
406                .map_err(|e| Status::invalid_argument(format!("json param encode error: {e}")))?;
407            Ok(ParamValue::Json(encoded.into_bytes()))
408        }
409        Kind::TimestampValue(value) => Ok(ParamValue::Timestamp(value)),
410        Kind::UuidValue(value) => {
411            let bytes: [u8; 16] = value.try_into().map_err(|value: Vec<u8>| {
412                Status::invalid_argument(format!(
413                    "uuid param must be 16 bytes, got {}",
414                    value.len()
415                ))
416            })?;
417            Ok(ParamValue::Uuid(bytes))
418        }
419    }
420}
421
422/// RAII guard scoping the runtime's thread-local identity to one gRPC SQL
423/// call. The catalog interceptor resolves the principal (`tenant/user` or
424/// `user`) but the runtime gates read a thread-local; without installing it
425/// every `Query`/`BatchQuery`/`ExecutePrepared` ran with enforcement off.
426/// The executor is synchronous, so the thread-local holds for the call.
427struct GrpcIdentityGuard {
428    previous_identity: Option<(String, crate::auth::Role)>,
429    previous_principal: Option<crate::auth::UserId>,
430    previous_tenant: Option<String>,
431}
432
433impl GrpcIdentityGuard {
434    fn install(identity: Option<(crate::auth::UserId, crate::auth::Role)>) -> Self {
435        use crate::runtime::execution_context as ctx;
436        let previous_identity = ctx::current_auth_identity_for_audit();
437        let previous_principal = ctx::current_auth_principal();
438        let previous_tenant = ctx::current_tenant();
439        match identity {
440            Some((id, role)) => {
441                match &id.tenant {
442                    Some(tenant) => ctx::set_current_tenant(tenant.clone()),
443                    None => ctx::clear_current_tenant(),
444                }
445                ctx::set_current_auth_principal(id, role);
446            }
447            None => {
448                ctx::clear_current_auth_identity();
449                ctx::clear_current_tenant();
450            }
451        }
452        Self {
453            previous_identity,
454            previous_principal,
455            previous_tenant,
456        }
457    }
458}
459
460impl Drop for GrpcIdentityGuard {
461    fn drop(&mut self) {
462        use crate::runtime::execution_context as ctx;
463        match (
464            self.previous_principal.take(),
465            self.previous_identity.take(),
466        ) {
467            (Some(principal), Some((_, role))) => ctx::set_current_auth_principal(principal, role),
468            _ => ctx::clear_current_auth_identity(),
469        }
470        match self.previous_tenant.take() {
471            Some(tenant) => ctx::set_current_tenant(tenant),
472            None => ctx::clear_current_tenant(),
473        }
474    }
475}
476
477/// Resolve the `(UserId, Role)` a gRPC request executes as from the typed
478/// dispatch context installed by the catalog interceptor.
479fn grpc_request_identity<T>(
480    request: &Request<T>,
481) -> Option<(crate::auth::UserId, crate::auth::Role)> {
482    request
483        .extensions()
484        .get::<catalog_dispatch::GrpcDispatchContext>()
485        .and_then(|context| context.identity.clone())
486}
487
488async fn grpc_spawn_blocking<F, R>(runtime: &RedDBRuntime, f: F) -> Result<R, Status>
489where
490    F: FnOnce() -> R + Send + 'static,
491    R: Send + 'static,
492{
493    use crate::transport::execution_admission::ExecutionAdmissionError;
494
495    let admission = runtime.execution_admission();
496    let permit =
497        match tokio::time::timeout(runtime.execution_queue_timeout(), admission.acquire()).await {
498            Ok(Ok(permit)) => permit,
499            Ok(Err(ExecutionAdmissionError::QueueFull)) => {
500                return Err(Status::resource_exhausted(
501                    "execution_queue_full: engine execution queue is full",
502                ))
503            }
504            Ok(Err(ExecutionAdmissionError::Closed)) => {
505                return Err(Status::unavailable(
506                    "execution_admission_closed: engine execution admission is unavailable",
507                ))
508            }
509            Err(_) => {
510                return Err(Status::deadline_exceeded(
511                    "execution_queue_timeout: deadline expired while waiting to execute",
512                ))
513            }
514        };
515    let bundle = crate::runtime::execution_context::snapshot_bundle();
516    tokio::task::spawn_blocking(move || {
517        let _permit = permit;
518        crate::runtime::execution_context::with_snapshot_bundle(&bundle, f)
519    })
520    .await
521    .map_err(|error| Status::internal(format!("engine execution task failed: {error}")))
522}
523
524fn execute_grpc_query_request(
525    runtime: &RedDBRuntime,
526    prepared: &PreparedRegistry,
527    identity: Option<(crate::auth::UserId, crate::auth::Role)>,
528    query: String,
529    params: Vec<QueryValue>,
530    commit_policy: Option<crate::replication::CommitPolicy>,
531) -> Result<crate::runtime::OwnedQueryResult, Status> {
532    if query.trim().is_empty() {
533        return Err(Status::invalid_argument("query field cannot be empty"));
534    }
535    let _identity = GrpcIdentityGuard::install(identity);
536
537    let params = params
538        .into_iter()
539        .map(grpc_query_value_to_param_value)
540        .collect::<Result<Vec<_>, _>>()?;
541    let mut request = RuntimeQueryRequest::sql(query, params);
542    if let Some(commit_policy) = commit_policy {
543        request = request.with_commit_policy(commit_policy);
544    }
545    QueryRequestExecutor::new(runtime, prepared)
546        .execute(request)
547        .map_err(grpc_query_request_error)
548}
549
550fn grpc_commit_policy_from_metadata(
551    metadata: &MetadataMap,
552) -> Result<Option<crate::replication::CommitPolicy>, Status> {
553    let Some(value) = metadata.get("x-reddb-commit-policy") else {
554        return Ok(None);
555    };
556    let value = value
557        .to_str()
558        .map_err(|_| Status::invalid_argument("x-reddb-commit-policy must be ascii"))?;
559    crate::replication::CommitPolicy::parse_strict(value)
560        .map(Some)
561        .ok_or_else(|| Status::invalid_argument(format!("invalid commit policy '{value}'")))
562}
563
564fn grpc_json_bind_to_param_value(value: &str) -> Result<ParamValue, Status> {
565    let value = json_from_str::<JsonValue>(value)
566        .map_err(|error| Status::invalid_argument(format!("bind parse error: {error}")))?;
567    Ok(match value {
568        JsonValue::Null => ParamValue::Null,
569        JsonValue::Bool(value) => ParamValue::Bool(value),
570        JsonValue::Integer(value) => ParamValue::Int64(value),
571        JsonValue::Number(value) if value.fract() == 0.0 && value.abs() < i64::MAX as f64 => {
572            ParamValue::Int64(value as i64)
573        }
574        JsonValue::Number(value) => ParamValue::Float64(value),
575        JsonValue::String(value) => ParamValue::Text(value),
576        other => ParamValue::Text(json_to_string(&other).unwrap_or_default()),
577    })
578}
579
580fn grpc_query_request_error(error: crate::api::RedDBError) -> Status {
581    let message = error.to_string();
582    if message.contains("commit policy timed out") {
583        return Status::deadline_exceeded(message);
584    }
585    match error {
586        crate::api::RedDBError::Query(message) if message.contains("not found or expired") => {
587            Status::not_found(message)
588        }
589        crate::api::RedDBError::Query(message)
590            if message == "prepared_needs_replan" || message == "prepared statements disabled" =>
591        {
592            Status::failed_precondition(message)
593        }
594        // Client-shaped request errors stay INVALID_ARGUMENT: seam bind
595        // failures and commit-policy floor violations.
596        crate::api::RedDBError::Query(message)
597            if message.contains("prepared bind failed")
598                || message.contains("weaker than resolved floor") =>
599        {
600            Status::invalid_argument(message)
601        }
602        crate::api::RedDBError::Validation { message, .. } => Status::invalid_argument(message),
603        // Everything else keeps the pre-migration status surface: to_status
604        // routes the ask_primary_sync_unavailable prefix to UNAVAILABLE and
605        // generic Query errors to INTERNAL.
606        other => to_status(other),
607    }
608}
609
610#[cfg(test)]
611mod grpc_query_value_tests {
612    use super::*;
613    use proto::query_value::Kind;
614
615    #[tokio::test]
616    async fn grpc_execution_refuses_when_shared_runtime_queue_is_full() {
617        let runtime =
618            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
619        runtime.configure_execution_admission(1, 0, 100);
620        let admission = runtime.execution_admission();
621        let active = admission.acquire().await.expect("active slot");
622
623        let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
624        assert_eq!(status.code(), tonic::Code::ResourceExhausted);
625        assert!(status.message().contains("execution_queue_full"));
626        drop(active);
627    }
628
629    #[tokio::test]
630    async fn grpc_execution_reports_queue_deadline_separately() {
631        let runtime =
632            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
633        runtime.configure_execution_admission(1, 1, 1);
634        let admission = runtime.execution_admission();
635        let active = admission.acquire().await.expect("active slot");
636
637        let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
638        assert_eq!(status.code(), tonic::Code::DeadlineExceeded);
639        assert!(status.message().contains("execution_queue_timeout"));
640        drop(active);
641    }
642
643    #[test]
644    fn grpc_query_value_maps_to_request_param_variants() {
645        let cases = vec![
646            (
647                QueryValue {
648                    kind: Some(Kind::NullValue(proto::QueryNull {})),
649                },
650                ParamValue::Null,
651            ),
652            (
653                QueryValue {
654                    kind: Some(Kind::BoolValue(true)),
655                },
656                ParamValue::Bool(true),
657            ),
658            (
659                QueryValue {
660                    kind: Some(Kind::IntValue(42)),
661                },
662                ParamValue::Int64(42),
663            ),
664            (
665                QueryValue {
666                    kind: Some(Kind::FloatValue(1.5)),
667                },
668                ParamValue::Float64(1.5),
669            ),
670            (
671                QueryValue {
672                    kind: Some(Kind::BytesValue(vec![0, 1, 2])),
673                },
674                ParamValue::Bytes(vec![0, 1, 2]),
675            ),
676            (
677                QueryValue {
678                    kind: Some(Kind::VectorValue(proto::QueryVector {
679                        values: vec![0.25, 0.5],
680                    })),
681                },
682                ParamValue::Vector(vec![0.25, 0.5]),
683            ),
684            (
685                QueryValue {
686                    kind: Some(Kind::TimestampValue(1_779_999_000)),
687                },
688                ParamValue::Timestamp(1_779_999_000),
689            ),
690            (
691                QueryValue {
692                    kind: Some(Kind::UuidValue(vec![0x11; 16])),
693                },
694                ParamValue::Uuid([0x11; 16]),
695            ),
696        ];
697
698        for (input, expected) in cases {
699            assert_eq!(grpc_query_value_to_param_value(input).unwrap(), expected);
700        }
701
702        assert_eq!(
703            grpc_query_value_to_param_value(QueryValue {
704                kind: Some(Kind::TextValue("alice".into())),
705            })
706            .unwrap(),
707            ParamValue::Text("alice".to_string())
708        );
709        assert_eq!(
710            grpc_query_value_to_param_value(QueryValue {
711                kind: Some(Kind::JsonValue("{\"role\":\"admin\"}".into())),
712            })
713            .unwrap(),
714            ParamValue::Json(b"{\"role\":\"admin\"}".to_vec())
715        );
716    }
717
718    #[test]
719    fn grpc_query_value_rejects_missing_kind_and_bad_uuid() {
720        assert!(grpc_query_value_to_param_value(QueryValue { kind: None }).is_err());
721        assert!(grpc_query_value_to_param_value(QueryValue {
722            kind: Some(Kind::UuidValue(vec![0; 15])),
723        })
724        .is_err());
725    }
726
727    #[test]
728    fn grpc_query_rejects_empty_query_before_runtime_parse() {
729        let runtime =
730            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
731        let prepared = PreparedRegistry::new();
732        let err = execute_grpc_query_request(
733            &runtime,
734            &prepared,
735            None,
736            "  ".to_string(),
737            Vec::new(),
738            None,
739        )
740        .expect_err("empty query should fail");
741
742        assert_eq!(err.code(), tonic::Code::InvalidArgument);
743        assert_eq!(err.message(), "query field cannot be empty");
744    }
745
746    #[test]
747    fn grpc_query_params_are_bound_before_execution() {
748        let runtime =
749            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
750        seed_grpc_param_table(&runtime);
751        let prepared = PreparedRegistry::new();
752
753        let result = execute_grpc_query_request(
754            &runtime,
755            &prepared,
756            None,
757            "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
758            grpc_param_values(),
759            None,
760        )
761        .expect("parameterized query");
762
763        assert_eq!(result.result.records.len(), 1);
764    }
765
766    #[test]
767    fn grpc_query_enforces_ack_n_commit_policy_fail_closed() {
768        let _env_lock = env_lock().lock().expect("env lock");
769        let _env = EnvGuard::set(&[
770            ("RED_PRIMARY_COMMIT_POLICY", "ack_n=1"),
771            ("RED_REPLICATION_ACK_TIMEOUT_MS", "20"),
772            ("RED_COMMIT_FAIL_ON_TIMEOUT", "true"),
773        ]);
774        let data_path = temp_data_path("grpc_ack_n_timeout");
775        cleanup(&data_path);
776
777        let runtime = RedDBRuntime::with_options(
778            crate::api::RedDBOptions::persistent(&data_path)
779                .with_replication(crate::replication::ReplicationConfig::primary()),
780        )
781        .expect("runtime");
782        let prepared = PreparedRegistry::new();
783
784        let err = execute_grpc_query_request(
785            &runtime,
786            &prepared,
787            None,
788            "INSERT INTO grpc_ack_items (id, name) VALUES (1, 'alpha')".to_string(),
789            Vec::new(),
790            None,
791        )
792        .expect_err("ack_n without replica ack must fail closed");
793
794        assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
795        assert!(
796            err.message().contains("commit policy timed out")
797                && err.message().contains("RED_COMMIT_FAIL_ON_TIMEOUT"),
798            "error should identify commit policy timeout, got {err:?}"
799        );
800        assert!(
801            runtime.cdc_current_lsn() > 0,
802            "local mutation should advance CDC before gRPC response fails"
803        );
804
805        cleanup(&data_path);
806    }
807
808    #[tokio::test]
809    async fn grpc_query_rpc_binds_query_request_params() {
810        let runtime =
811            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
812        seed_grpc_param_table(&runtime);
813        let service = GrpcRuntime {
814            runtime,
815            auth_store: Arc::new(AuthStore::new(crate::auth::AuthConfig::default())),
816            prepared_registry: Arc::new(PreparedRegistry::new()),
817            oauth_validator: None,
818        };
819
820        let reply = RedDb::query(
821            &service,
822            Request::new(QueryRequest {
823                query: "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
824                entity_types: Vec::new(),
825                capabilities: Vec::new(),
826                params: grpc_param_values(),
827            }),
828        )
829        .await
830        .expect("query rpc")
831        .into_inner();
832
833        assert_eq!(reply.record_count, 1);
834        assert!(reply.result_json.contains("Alice"), "{}", reply.result_json);
835        assert!(!reply.result_json.contains("Bob"), "{}", reply.result_json);
836    }
837
838    #[tokio::test]
839    async fn grpc_prepared_id_is_shared_by_connection_listeners_only() {
840        let runtime =
841            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
842        let server = RedDBGrpcServer::new(runtime.clone());
843        let plain_listener = server.grpc_runtime();
844        let tls_listener = server.grpc_runtime();
845        let second_connection = RedDBGrpcServer::new(runtime).grpc_runtime();
846
847        let prepared = RedDb::prepare_query(
848            &plain_listener,
849            Request::new(PrepareQueryRequest {
850                query: "SELECT 7 AS value".to_string(),
851            }),
852        )
853        .await
854        .expect("prepare on plaintext listener")
855        .into_inner();
856
857        let reply = RedDb::execute_prepared(
858            &tls_listener,
859            Request::new(ExecutePreparedRequest {
860                prepared_id: prepared.prepared_id,
861                bind_json: vec!["42".to_string()],
862            }),
863        )
864        .await
865        .expect("execute on TLS listener of the same connection")
866        .into_inner();
867        assert!(reply.result_json.contains("42"), "{}", reply.result_json);
868
869        let error = RedDb::execute_prepared(
870            &second_connection,
871            Request::new(ExecutePreparedRequest {
872                prepared_id: prepared.prepared_id,
873                bind_json: vec!["42".to_string()],
874            }),
875        )
876        .await
877        .expect_err("another connection must not resolve the prepared ID");
878        assert_eq!(error.code(), tonic::Code::NotFound);
879    }
880
881    #[tokio::test]
882    async fn grpc_execute_prepared_rejects_a_stale_ddl_epoch() {
883        let runtime =
884            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
885        let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
886        let prepared = RedDb::prepare_query(
887            &service,
888            Request::new(PrepareQueryRequest {
889                query: "SELECT 7 AS value".to_string(),
890            }),
891        )
892        .await
893        .expect("prepare query")
894        .into_inner();
895
896        runtime
897            .execute_query("CREATE TABLE grpc_prepared_epoch (id INTEGER)")
898            .expect("execute DDL");
899
900        let error = RedDb::execute_prepared(
901            &service,
902            Request::new(ExecutePreparedRequest {
903                prepared_id: prepared.prepared_id,
904                bind_json: vec!["42".to_string()],
905            }),
906        )
907        .await
908        .expect_err("DDL must invalidate the gRPC prepared shape");
909        assert_eq!(error.code(), tonic::Code::FailedPrecondition);
910        assert_eq!(error.message(), "prepared_needs_replan");
911    }
912
913    #[tokio::test]
914    async fn grpc_prepared_kill_switch_rejects_prepare_and_execute() {
915        let runtime =
916            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
917        let service = RedDBGrpcServer::new(runtime).grpc_runtime();
918        let prepared = RedDb::prepare_query(
919            &service,
920            Request::new(PrepareQueryRequest {
921                query: "SELECT 7 AS value".to_string(),
922            }),
923        )
924        .await
925        .expect("prepare before kill switch")
926        .into_inner();
927
928        service.prepared_registry.disable();
929
930        let prepare_error = RedDb::prepare_query(
931            &service,
932            Request::new(PrepareQueryRequest {
933                query: "SELECT 1".to_string(),
934            }),
935        )
936        .await
937        .expect_err("kill switch must reject prepare");
938        assert_eq!(prepare_error.code(), tonic::Code::FailedPrecondition);
939        assert_eq!(prepare_error.message(), "prepared statements disabled");
940
941        let execute_error = RedDb::execute_prepared(
942            &service,
943            Request::new(ExecutePreparedRequest {
944                prepared_id: prepared.prepared_id,
945                bind_json: vec!["42".to_string()],
946            }),
947        )
948        .await
949        .expect_err("kill switch must reject execute");
950        assert_eq!(execute_error.code(), tonic::Code::FailedPrecondition);
951        assert_eq!(execute_error.message(), "prepared statements disabled");
952    }
953
954    #[tokio::test]
955    async fn grpc_query_honors_per_request_commit_policy() {
956        let _env_lock = env_lock().lock().expect("env lock");
957        let _env = EnvGuard::set(&[("RED_PRIMARY_COMMIT_POLICY", "ack_n=1")]);
958        let runtime =
959            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
960        runtime
961            .execute_query("CREATE TABLE grpc_request_policy (id INTEGER)")
962            .expect("create table");
963        let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
964        let mut request = Request::new(QueryRequest {
965            query: "INSERT INTO grpc_request_policy (id) VALUES (1)".to_string(),
966            entity_types: Vec::new(),
967            capabilities: Vec::new(),
968            params: Vec::new(),
969        });
970        request
971            .metadata_mut()
972            .insert("x-reddb-commit-policy", "local".parse().expect("metadata"));
973
974        let error = RedDb::query(&service, request)
975            .await
976            .expect_err("weaker per-request policy must fail before mutation");
977        assert_eq!(error.code(), tonic::Code::InvalidArgument);
978        assert!(error.message().contains("weaker than resolved floor"));
979
980        let rows = runtime
981            .execute_query("SELECT id FROM grpc_request_policy")
982            .expect("read rows");
983        assert!(rows.result.records.is_empty());
984    }
985
986    #[tokio::test]
987    async fn pull_wal_records_rejects_stale_term_on_grpc_path() {
988        let runtime = RedDBRuntime::with_options(
989            crate::api::RedDBOptions::in_memory()
990                .with_replication(crate::replication::ReplicationConfig::primary().with_term(6)),
991        )
992        .expect("runtime");
993        let auth_store = Arc::new(AuthStore::new(crate::auth::AuthConfig {
994            enabled: true,
995            require_auth: true,
996            ..crate::auth::AuthConfig::default()
997        }));
998        let bootstrap = auth_store
999            .bootstrap("replica", "secret")
1000            .expect("bootstrap");
1001        let policy = crate::auth::policies::Policy::from_json_str(
1002            r#"{
1003                "id": "replication-stream",
1004                "version": 1,
1005                "statements": [{
1006                    "effect": "allow",
1007                    "actions": ["cluster:replication:stream"],
1008                    "resources": ["cluster:replication"]
1009                }]
1010            }"#,
1011        )
1012        .expect("policy");
1013        auth_store.put_policy(policy).expect("install policy");
1014        auth_store
1015            .attach_policy(
1016                crate::auth::store::PrincipalRef::User(crate::auth::UserId::platform("replica")),
1017                "replication-stream",
1018            )
1019            .expect("attach policy");
1020        let service = GrpcRuntime {
1021            runtime,
1022            auth_store,
1023            prepared_registry: Arc::new(PreparedRegistry::new()),
1024            oauth_validator: None,
1025        };
1026
1027        let open = reddb_wire::replication::WalStreamOpen {
1028            since_lsn: 0,
1029            max_count: 1,
1030            replica_id: Some("replica-a".to_string()),
1031            term: 5,
1032            await_data: false,
1033            await_timeout_ms: 1,
1034        };
1035        let mut request = Request::new(JsonPayloadRequest {
1036            payload_json: String::from_utf8(open.encode_json()).expect("json"),
1037        });
1038        request.metadata_mut().insert(
1039            "authorization",
1040            format!("Bearer {}", bootstrap.api_key.key)
1041                .parse()
1042                .expect("metadata"),
1043        );
1044
1045        let err = RedDb::pull_wal_records(&service, request)
1046            .await
1047            .expect_err("stale term should be fenced");
1048
1049        assert_eq!(err.code(), tonic::Code::FailedPrecondition);
1050        assert!(
1051            err.message().contains("stale")
1052                || err.message().contains("fenced")
1053                || err.message().contains("current term"),
1054            "unexpected stale-term error: {err:?}"
1055        );
1056    }
1057
1058    fn seed_grpc_param_table(runtime: &RedDBRuntime) {
1059        runtime
1060            .execute_query("CREATE TABLE p (id INTEGER, name TEXT)")
1061            .expect("create table");
1062        runtime
1063            .execute_query("INSERT INTO p (id, name) VALUES (1, 'Alice')")
1064            .expect("insert alice");
1065        runtime
1066            .execute_query("INSERT INTO p (id, name) VALUES (2, 'Bob')")
1067            .expect("insert bob");
1068    }
1069
1070    fn grpc_param_values() -> Vec<QueryValue> {
1071        vec![
1072            QueryValue {
1073                kind: Some(Kind::IntValue(1)),
1074            },
1075            QueryValue {
1076                kind: Some(Kind::TextValue("Alice".to_string())),
1077            },
1078        ]
1079    }
1080
1081    fn env_lock() -> &'static std::sync::Mutex<()> {
1082        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1083        LOCK.get_or_init(|| std::sync::Mutex::new(()))
1084    }
1085
1086    struct EnvGuard {
1087        previous: Vec<(&'static str, Option<String>)>,
1088    }
1089
1090    impl EnvGuard {
1091        fn set(vars: &[(&'static str, &'static str)]) -> Self {
1092            let previous = vars
1093                .iter()
1094                .map(|(key, _)| (*key, std::env::var(key).ok()))
1095                .collect();
1096            for (key, value) in vars {
1097                std::env::set_var(key, value);
1098            }
1099            Self { previous }
1100        }
1101    }
1102
1103    impl Drop for EnvGuard {
1104        fn drop(&mut self) {
1105            for (key, value) in self.previous.iter().rev() {
1106                match value {
1107                    Some(value) => std::env::set_var(key, value),
1108                    None => std::env::remove_var(key),
1109                }
1110            }
1111        }
1112    }
1113
1114    fn temp_data_path(name: &str) -> std::path::PathBuf {
1115        let suffix = SystemTime::now()
1116            .duration_since(UNIX_EPOCH)
1117            .unwrap()
1118            .as_nanos();
1119        std::env::temp_dir().join(format!("reddb_{name}_{suffix}.rdb"))
1120    }
1121
1122    fn cleanup(data_path: &std::path::Path) {
1123        let _ = std::fs::remove_file(data_path);
1124        let _ = std::fs::remove_file(
1125            crate::replication::primary::PrimaryReplication::slot_path_for(data_path),
1126        );
1127        let _ = std::fs::remove_file(crate::replication::primary::LogicalWalSpool::path_for(
1128            data_path,
1129        ));
1130        let _ = std::fs::remove_dir_all(
1131            crate::replication::primary::PrimaryReplication::primary_replica_root_for(data_path),
1132        );
1133        reddb_file::cleanup_rebootstrap_artifacts(data_path);
1134    }
1135}
1136
1137#[cfg(test)]
1138mod grpc_ask_query_reply_tests {
1139    use super::*;
1140    use crate::storage::query::unified::{UnifiedRecord, UnifiedResult};
1141    use reddb_rql::modes::QueryMode;
1142    use reddb_types::Value as SchemaValue;
1143
1144    fn ask_runtime_result() -> RuntimeQueryResult {
1145        let mut result = UnifiedResult::with_columns(vec![
1146            "answer".into(),
1147            "provider".into(),
1148            "model".into(),
1149            "mode".into(),
1150            "retry_count".into(),
1151            "prompt_tokens".into(),
1152            "completion_tokens".into(),
1153            "sources_flat".into(),
1154            "citations".into(),
1155            "validation".into(),
1156        ]);
1157        let mut record = UnifiedRecord::new();
1158        record.set("answer", SchemaValue::text("Deploy failed [^1]."));
1159        record.set("provider", SchemaValue::text("openai"));
1160        record.set("model", SchemaValue::text("gpt-4o-mini"));
1161        record.set("mode", SchemaValue::text("strict"));
1162        record.set("retry_count", SchemaValue::Integer(0));
1163        record.set("prompt_tokens", SchemaValue::Integer(11));
1164        record.set("completion_tokens", SchemaValue::Integer(7));
1165        record.set(
1166            "sources_flat",
1167            SchemaValue::Json(
1168                br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
1169            ),
1170        );
1171        record.set(
1172            "citations",
1173            SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
1174        );
1175        record.set(
1176            "validation",
1177            SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
1178        );
1179        result.push(record);
1180
1181        RuntimeQueryResult {
1182            query: "ASK 'why did deploy fail?'".to_string(),
1183            mode: QueryMode::Sql,
1184            statement: "ask",
1185            engine: "runtime-ai",
1186            result,
1187            affected_rows: 0,
1188            statement_type: "select",
1189            bookmark: None,
1190            notice: None,
1191        }
1192    }
1193
1194    #[test]
1195    fn query_reply_ask_result_json_uses_full_canonical_schema() {
1196        let reply = query_reply(ask_runtime_result(), &None, &None);
1197        let json: crate::json::Value =
1198            crate::json::from_str(&reply.result_json).expect("valid ask json");
1199
1200        assert_eq!(
1201            json.get("answer").and_then(crate::json::Value::as_str),
1202            Some("Deploy failed [^1].")
1203        );
1204        assert_eq!(
1205            json.get("cache_hit").and_then(crate::json::Value::as_bool),
1206            Some(false)
1207        );
1208        assert_eq!(
1209            json.get("cost_usd").and_then(crate::json::Value::as_f64),
1210            Some(0.0)
1211        );
1212        assert_eq!(
1213            json.get("mode").and_then(crate::json::Value::as_str),
1214            Some("strict")
1215        );
1216        assert_eq!(
1217            json.get("retry_count").and_then(crate::json::Value::as_u64),
1218            Some(0)
1219        );
1220        assert!(
1221            json.get("records").is_none(),
1222            "ASK must not be row-wrapped: {}",
1223            reply.result_json
1224        );
1225        assert!(
1226            json.get("sources_flat")
1227                .and_then(crate::json::Value::as_array)
1228                .is_some_and(|sources| sources.len() == 1
1229                    && sources[0]
1230                        .get("payload")
1231                        .and_then(crate::json::Value::as_str)
1232                        .is_some()),
1233            "sources_flat must be parsed with payload fallback: {}",
1234            reply.result_json
1235        );
1236        assert!(
1237            json.get("citations")
1238                .and_then(crate::json::Value::as_array)
1239                .is_some_and(|citations| citations.len() == 1),
1240            "citations must be parsed: {}",
1241            reply.result_json
1242        );
1243        assert_eq!(
1244            json.get("validation")
1245                .and_then(|v| v.get("ok"))
1246                .and_then(crate::json::Value::as_bool),
1247            Some(true)
1248        );
1249    }
1250
1251    #[test]
1252    fn query_reply_non_ask_answer_column_keeps_row_shape() {
1253        let mut result = UnifiedResult::with_columns(vec!["answer".into()]);
1254        let mut record = UnifiedRecord::new();
1255        record.set("answer", SchemaValue::text("plain select"));
1256        result.push(record);
1257
1258        let reply = query_reply(
1259            RuntimeQueryResult {
1260                query: "SELECT 'plain select' AS answer".to_string(),
1261                mode: QueryMode::Sql,
1262                statement: "select",
1263                engine: "runtime-sql",
1264                result,
1265                affected_rows: 0,
1266                statement_type: "select",
1267                bookmark: None,
1268                notice: None,
1269            },
1270            &None,
1271            &None,
1272        );
1273        let json: crate::json::Value =
1274            crate::json::from_str(&reply.result_json).expect("valid query json");
1275
1276        assert!(
1277            json.get("records").is_some(),
1278            "non-ASK must stay row-wrapped"
1279        );
1280        assert!(
1281            json.get("answer").is_none(),
1282            "non-ASK must not use ASK envelope"
1283        );
1284    }
1285
1286    #[test]
1287    fn query_reply_non_ask_json_column_preserves_object() {
1288        let mut result = UnifiedResult::with_columns(vec!["value".into()]);
1289        let mut record = UnifiedRecord::new();
1290        record.set(
1291            "value",
1292            SchemaValue::Json(br#"{"alpha":"A","nested":{"leaf":12}}"#.to_vec()),
1293        );
1294        result.push(record);
1295
1296        let reply = query_reply(
1297            RuntimeQueryResult {
1298                query: "LIST KV proj AS JSON".to_string(),
1299                mode: QueryMode::Sql,
1300                statement: "kv_list_json",
1301                engine: "kv",
1302                result,
1303                affected_rows: 0,
1304                statement_type: "select",
1305                bookmark: None,
1306                notice: None,
1307            },
1308            &None,
1309            &None,
1310        );
1311        let json: crate::json::Value =
1312            crate::json::from_str(&reply.result_json).expect("valid query json");
1313        let value = json
1314            .get("records")
1315            .and_then(crate::json::Value::as_array)
1316            .and_then(|records| records.first())
1317            .and_then(|record| record.get("value"))
1318            .expect("value column");
1319
1320        assert_eq!(
1321            value.get("alpha").and_then(crate::json::Value::as_str),
1322            Some("A")
1323        );
1324        assert_eq!(
1325            value
1326                .get("nested")
1327                .and_then(|nested| nested.get("leaf"))
1328                .and_then(crate::json::Value::as_f64),
1329            Some(12.0)
1330        );
1331    }
1332}
1333
1334/// Emit a single info-level event with the SHA-256 fingerprint of the
1335/// active gRPC server cert + an mTLS flag. Never logs PEM bytes.
1336fn log_grpc_tls_identity(tls: &GrpcTlsOptions) {
1337    use sha2::{Digest, Sha256};
1338    let cert_fp = {
1339        let mut h = Sha256::new();
1340        h.update(&tls.cert_pem);
1341        let digest = h.finalize();
1342        // First 16 hex chars are enough for human cross-check; the full
1343        // SHA-256 lives in audit logs only.
1344        let mut buf = String::with_capacity(64);
1345        for b in digest.iter() {
1346            buf.push_str(&format!("{b:02x}"));
1347        }
1348        buf
1349    };
1350    tracing::info!(
1351        target: "reddb::security",
1352        transport = "grpc",
1353        cert_sha256 = %cert_fp,
1354        mtls = tls.client_ca_pem.is_some(),
1355        "gRPC TLS identity loaded"
1356    );
1357}
1358
1359include!("grpc/service_impl.rs");