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    TypedCreateRowRequest, UpdateEntityRequest, Validation, ValidationItem,
67    WalCatchup as GrpcWalCatchup, WalStreamChunk as GrpcWalStreamChunk,
68    WalStreamRecord as GrpcWalStreamRecord, WalStreamRequest as GrpcWalStreamRequest,
69};
70
71pub(crate) mod catalog_dispatch;
72pub(crate) mod control_support;
73mod entity_ops;
74mod input_support;
75pub(crate) mod scan_json;
76
77use self::catalog_dispatch::*;
78use self::control_support::*;
79use self::entity_ops::*;
80use self::input_support::*;
81use self::scan_json::*;
82
83#[derive(Debug, Clone)]
84pub struct GrpcServerOptions {
85    pub bind_addr: String,
86    /// 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    fn mutation_field(name: &str, kind: Kind) -> proto::MutationField {
619        proto::MutationField {
620            name: name.to_string(),
621            value: Some(QueryValue { kind: Some(kind) }),
622        }
623    }
624
625    #[tokio::test]
626    async fn grpc_execution_refuses_when_shared_runtime_queue_is_full() {
627        let runtime =
628            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
629        runtime.configure_execution_admission(1, 0, 100);
630        let admission = runtime.execution_admission();
631        let active = admission.acquire().await.expect("active slot");
632
633        let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
634        assert_eq!(status.code(), tonic::Code::ResourceExhausted);
635        assert!(status.message().contains("execution_queue_full"));
636        drop(active);
637    }
638
639    #[tokio::test]
640    async fn grpc_execution_reports_queue_deadline_separately() {
641        let runtime =
642            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
643        runtime.configure_execution_admission(1, 1, 1);
644        let admission = runtime.execution_admission();
645        let active = admission.acquire().await.expect("active slot");
646
647        let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
648        assert_eq!(status.code(), tonic::Code::DeadlineExceeded);
649        assert!(status.message().contains("execution_queue_timeout"));
650        drop(active);
651    }
652
653    #[test]
654    fn grpc_query_value_maps_to_request_param_variants() {
655        let cases = vec![
656            (
657                QueryValue {
658                    kind: Some(Kind::NullValue(proto::QueryNull {})),
659                },
660                ParamValue::Null,
661            ),
662            (
663                QueryValue {
664                    kind: Some(Kind::BoolValue(true)),
665                },
666                ParamValue::Bool(true),
667            ),
668            (
669                QueryValue {
670                    kind: Some(Kind::IntValue(42)),
671                },
672                ParamValue::Int64(42),
673            ),
674            (
675                QueryValue {
676                    kind: Some(Kind::FloatValue(1.5)),
677                },
678                ParamValue::Float64(1.5),
679            ),
680            (
681                QueryValue {
682                    kind: Some(Kind::BytesValue(vec![0, 1, 2])),
683                },
684                ParamValue::Bytes(vec![0, 1, 2]),
685            ),
686            (
687                QueryValue {
688                    kind: Some(Kind::VectorValue(proto::QueryVector {
689                        values: vec![0.25, 0.5],
690                    })),
691                },
692                ParamValue::Vector(vec![0.25, 0.5]),
693            ),
694            (
695                QueryValue {
696                    kind: Some(Kind::TimestampValue(1_779_999_000)),
697                },
698                ParamValue::Timestamp(1_779_999_000),
699            ),
700            (
701                QueryValue {
702                    kind: Some(Kind::UuidValue(vec![0x11; 16])),
703                },
704                ParamValue::Uuid([0x11; 16]),
705            ),
706        ];
707
708        for (input, expected) in cases {
709            assert_eq!(grpc_query_value_to_param_value(input).unwrap(), expected);
710        }
711
712        assert_eq!(
713            grpc_query_value_to_param_value(QueryValue {
714                kind: Some(Kind::TextValue("alice".into())),
715            })
716            .unwrap(),
717            ParamValue::Text("alice".to_string())
718        );
719        assert_eq!(
720            grpc_query_value_to_param_value(QueryValue {
721                kind: Some(Kind::JsonValue("{\"role\":\"admin\"}".into())),
722            })
723            .unwrap(),
724            ParamValue::Json(b"{\"role\":\"admin\"}".to_vec())
725        );
726    }
727
728    #[test]
729    fn grpc_query_value_rejects_missing_kind_and_bad_uuid() {
730        assert!(grpc_query_value_to_param_value(QueryValue { kind: None }).is_err());
731        assert!(grpc_query_value_to_param_value(QueryValue {
732            kind: Some(Kind::UuidValue(vec![0; 15])),
733        })
734        .is_err());
735    }
736
737    #[test]
738    fn grpc_query_rejects_empty_query_before_runtime_parse() {
739        let runtime =
740            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
741        let prepared = PreparedRegistry::new();
742        let err = execute_grpc_query_request(
743            &runtime,
744            &prepared,
745            None,
746            "  ".to_string(),
747            Vec::new(),
748            None,
749        )
750        .expect_err("empty query should fail");
751
752        assert_eq!(err.code(), tonic::Code::InvalidArgument);
753        assert_eq!(err.message(), "query field cannot be empty");
754    }
755
756    #[test]
757    fn grpc_query_params_are_bound_before_execution() {
758        let runtime =
759            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
760        seed_grpc_param_table(&runtime);
761        let prepared = PreparedRegistry::new();
762
763        let result = execute_grpc_query_request(
764            &runtime,
765            &prepared,
766            None,
767            "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
768            grpc_param_values(),
769            None,
770        )
771        .expect("parameterized query");
772
773        assert_eq!(result.result.records.len(), 1);
774    }
775
776    #[test]
777    fn grpc_query_enforces_ack_n_commit_policy_fail_closed() {
778        let _env_lock = env_lock().lock().expect("env lock");
779        let _env = EnvGuard::set(&[
780            ("RED_PRIMARY_COMMIT_POLICY", "ack_n=1"),
781            ("RED_REPLICATION_ACK_TIMEOUT_MS", "20"),
782            ("RED_COMMIT_FAIL_ON_TIMEOUT", "false"),
783        ]);
784        let data_path = temp_data_path("grpc_ack_n_timeout");
785        cleanup(&data_path);
786
787        let runtime = RedDBRuntime::with_options(
788            crate::api::RedDBOptions::persistent(&data_path)
789                .with_replication(crate::replication::ReplicationConfig::primary()),
790        )
791        .expect("runtime");
792        let prepared = PreparedRegistry::new();
793
794        let err = execute_grpc_query_request(
795            &runtime,
796            &prepared,
797            None,
798            "INSERT INTO grpc_ack_items (id, name) VALUES (1, 'alpha')".to_string(),
799            Vec::new(),
800            None,
801        )
802        .expect_err("ack_n without replica ack must fail closed");
803
804        assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
805        assert!(
806            err.message().contains("commit policy timed out")
807                && err.message().contains("local commit is durable"),
808            "error should identify commit policy timeout, got {err:?}"
809        );
810        assert!(
811            runtime.cdc_current_lsn() > 0,
812            "local mutation should advance CDC before gRPC response fails"
813        );
814
815        cleanup(&data_path);
816    }
817
818    #[tokio::test]
819    async fn grpc_query_rpc_binds_query_request_params() {
820        let runtime =
821            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
822        seed_grpc_param_table(&runtime);
823        let service = GrpcRuntime {
824            runtime,
825            auth_store: Arc::new(AuthStore::new(crate::auth::AuthConfig::default())),
826            prepared_registry: Arc::new(PreparedRegistry::new()),
827            oauth_validator: None,
828        };
829
830        let reply = RedDb::query(
831            &service,
832            Request::new(QueryRequest {
833                query: "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
834                entity_types: Vec::new(),
835                capabilities: Vec::new(),
836                params: grpc_param_values(),
837            }),
838        )
839        .await
840        .expect("query rpc")
841        .into_inner();
842
843        assert_eq!(reply.record_count, 1);
844        assert!(reply.result_json.contains("Alice"), "{}", reply.result_json);
845        assert!(!reply.result_json.contains("Bob"), "{}", reply.result_json);
846    }
847
848    #[tokio::test]
849    async fn grpc_typed_create_row_preserves_types_and_uses_contract_defaults() {
850        let runtime =
851            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
852        runtime
853            .execute_query(
854                "CREATE TABLE typed_users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, active BOOLEAN DEFAULT = true)",
855            )
856            .expect("create typed table");
857        let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
858
859        let reply = RedDb::create_row_typed(
860            &service,
861            Request::new(TypedCreateRowRequest {
862                collection: "typed_users".to_string(),
863                fields: vec![
864                    mutation_field("id", Kind::IntValue(7)),
865                    mutation_field("name", Kind::TextValue("Ada".to_string())),
866                ],
867            }),
868        )
869        .await
870        .expect("typed insert")
871        .into_inner();
872
873        assert!(reply.ok);
874        let selected = runtime
875            .execute_query("SELECT id, name, active FROM typed_users WHERE id = 7")
876            .expect("select typed row");
877        assert_eq!(selected.result.records.len(), 1);
878        assert_eq!(
879            selected.result.records[0].get("id"),
880            Some(&Value::Integer(7))
881        );
882        assert_eq!(
883            selected.result.records[0].get("name"),
884            Some(&Value::text("Ada"))
885        );
886        assert_eq!(
887            selected.result.records[0].get("active"),
888            Some(&Value::Boolean(true))
889        );
890    }
891
892    #[tokio::test]
893    async fn grpc_typed_create_row_rejects_ambiguous_or_invalid_fields_before_write() {
894        let runtime =
895            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
896        runtime
897            .execute_query(
898                "CREATE TABLE typed_rejects (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
899            )
900            .expect("create typed table");
901        let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
902
903        let duplicate = RedDb::create_row_typed(
904            &service,
905            Request::new(TypedCreateRowRequest {
906                collection: "typed_rejects".to_string(),
907                fields: vec![
908                    mutation_field("id", Kind::IntValue(1)),
909                    mutation_field("id", Kind::IntValue(2)),
910                    mutation_field("name", Kind::TextValue("Ada".to_string())),
911                ],
912            }),
913        )
914        .await
915        .expect_err("duplicate field must fail");
916        assert_eq!(duplicate.code(), tonic::Code::InvalidArgument);
917        assert!(duplicate.message().contains("duplicate field 'id'"));
918
919        let missing_kind = RedDb::create_row_typed(
920            &service,
921            Request::new(TypedCreateRowRequest {
922                collection: "typed_rejects".to_string(),
923                fields: vec![proto::MutationField {
924                    name: "id".to_string(),
925                    value: Some(QueryValue { kind: None }),
926                }],
927            }),
928        )
929        .await
930        .expect_err("missing value kind must fail");
931        assert_eq!(missing_kind.code(), tonic::Code::InvalidArgument);
932
933        let selected = runtime
934            .execute_query("SELECT id FROM typed_rejects")
935            .expect("select after rejected inserts");
936        assert!(selected.result.records.is_empty());
937    }
938
939    #[tokio::test]
940    async fn grpc_prepared_id_is_shared_by_connection_listeners_only() {
941        let runtime =
942            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
943        let server = RedDBGrpcServer::new(runtime.clone());
944        let plain_listener = server.grpc_runtime();
945        let tls_listener = server.grpc_runtime();
946        let second_connection = RedDBGrpcServer::new(runtime).grpc_runtime();
947
948        let prepared = RedDb::prepare_query(
949            &plain_listener,
950            Request::new(PrepareQueryRequest {
951                query: "SELECT 7 AS value".to_string(),
952            }),
953        )
954        .await
955        .expect("prepare on plaintext listener")
956        .into_inner();
957
958        let reply = RedDb::execute_prepared(
959            &tls_listener,
960            Request::new(ExecutePreparedRequest {
961                prepared_id: prepared.prepared_id,
962                bind_json: vec!["42".to_string()],
963            }),
964        )
965        .await
966        .expect("execute on TLS listener of the same connection")
967        .into_inner();
968        assert!(reply.result_json.contains("42"), "{}", reply.result_json);
969
970        let error = RedDb::execute_prepared(
971            &second_connection,
972            Request::new(ExecutePreparedRequest {
973                prepared_id: prepared.prepared_id,
974                bind_json: vec!["42".to_string()],
975            }),
976        )
977        .await
978        .expect_err("another connection must not resolve the prepared ID");
979        assert_eq!(error.code(), tonic::Code::NotFound);
980    }
981
982    #[tokio::test]
983    async fn grpc_execute_prepared_rejects_a_stale_ddl_epoch() {
984        let runtime =
985            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
986        let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
987        let prepared = RedDb::prepare_query(
988            &service,
989            Request::new(PrepareQueryRequest {
990                query: "SELECT 7 AS value".to_string(),
991            }),
992        )
993        .await
994        .expect("prepare query")
995        .into_inner();
996
997        runtime
998            .execute_query("CREATE TABLE grpc_prepared_epoch (id INTEGER)")
999            .expect("execute DDL");
1000
1001        let error = RedDb::execute_prepared(
1002            &service,
1003            Request::new(ExecutePreparedRequest {
1004                prepared_id: prepared.prepared_id,
1005                bind_json: vec!["42".to_string()],
1006            }),
1007        )
1008        .await
1009        .expect_err("DDL must invalidate the gRPC prepared shape");
1010        assert_eq!(error.code(), tonic::Code::FailedPrecondition);
1011        assert_eq!(error.message(), "prepared_needs_replan");
1012    }
1013
1014    #[tokio::test]
1015    async fn grpc_prepared_kill_switch_rejects_prepare_and_execute() {
1016        let runtime =
1017            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
1018        let service = RedDBGrpcServer::new(runtime).grpc_runtime();
1019        let prepared = RedDb::prepare_query(
1020            &service,
1021            Request::new(PrepareQueryRequest {
1022                query: "SELECT 7 AS value".to_string(),
1023            }),
1024        )
1025        .await
1026        .expect("prepare before kill switch")
1027        .into_inner();
1028
1029        service.prepared_registry.disable();
1030
1031        let prepare_error = RedDb::prepare_query(
1032            &service,
1033            Request::new(PrepareQueryRequest {
1034                query: "SELECT 1".to_string(),
1035            }),
1036        )
1037        .await
1038        .expect_err("kill switch must reject prepare");
1039        assert_eq!(prepare_error.code(), tonic::Code::FailedPrecondition);
1040        assert_eq!(prepare_error.message(), "prepared statements disabled");
1041
1042        let execute_error = RedDb::execute_prepared(
1043            &service,
1044            Request::new(ExecutePreparedRequest {
1045                prepared_id: prepared.prepared_id,
1046                bind_json: vec!["42".to_string()],
1047            }),
1048        )
1049        .await
1050        .expect_err("kill switch must reject execute");
1051        assert_eq!(execute_error.code(), tonic::Code::FailedPrecondition);
1052        assert_eq!(execute_error.message(), "prepared statements disabled");
1053    }
1054
1055    #[tokio::test]
1056    async fn grpc_query_honors_per_request_commit_policy() {
1057        let _env_lock = env_lock().lock().expect("env lock");
1058        let _env = EnvGuard::set(&[("RED_PRIMARY_COMMIT_POLICY", "ack_n=1")]);
1059        let runtime = RedDBRuntime::with_options(
1060            crate::api::RedDBOptions::in_memory()
1061                .with_replication(crate::replication::ReplicationConfig::primary()),
1062        )
1063        .expect("runtime");
1064        runtime
1065            .execute_query("CREATE TABLE grpc_request_policy (id INTEGER)")
1066            .expect("create table");
1067        let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
1068        let mut request = Request::new(QueryRequest {
1069            query: "INSERT INTO grpc_request_policy (id) VALUES (1)".to_string(),
1070            entity_types: Vec::new(),
1071            capabilities: Vec::new(),
1072            params: Vec::new(),
1073        });
1074        request
1075            .metadata_mut()
1076            .insert("x-reddb-commit-policy", "local".parse().expect("metadata"));
1077
1078        let error = RedDb::query(&service, request)
1079            .await
1080            .expect_err("weaker per-request policy must fail before mutation");
1081        assert_eq!(error.code(), tonic::Code::InvalidArgument);
1082        assert!(error.message().contains("weaker than resolved floor"));
1083
1084        let rows = runtime
1085            .execute_query("SELECT id FROM grpc_request_policy")
1086            .expect("read rows");
1087        assert!(rows.result.records.is_empty());
1088    }
1089
1090    #[tokio::test]
1091    async fn pull_wal_records_rejects_stale_term_on_grpc_path() {
1092        let runtime = RedDBRuntime::with_options(
1093            crate::api::RedDBOptions::in_memory()
1094                .with_replication(crate::replication::ReplicationConfig::primary().with_term(6)),
1095        )
1096        .expect("runtime");
1097        let auth_store = Arc::new(AuthStore::new(crate::auth::AuthConfig {
1098            enabled: true,
1099            require_auth: true,
1100            ..crate::auth::AuthConfig::default()
1101        }));
1102        let bootstrap = auth_store
1103            .bootstrap("replica", "secret")
1104            .expect("bootstrap");
1105        let policy = crate::auth::policies::Policy::from_json_str(
1106            r#"{
1107                "id": "replication-stream",
1108                "version": 1,
1109                "statements": [{
1110                    "effect": "allow",
1111                    "actions": ["cluster:replication:stream"],
1112                    "resources": ["cluster:replication"]
1113                }]
1114            }"#,
1115        )
1116        .expect("policy");
1117        auth_store.put_policy(policy).expect("install policy");
1118        auth_store
1119            .attach_policy(
1120                crate::auth::store::PrincipalRef::User(crate::auth::UserId::platform("replica")),
1121                "replication-stream",
1122            )
1123            .expect("attach policy");
1124        let service = GrpcRuntime {
1125            runtime,
1126            auth_store,
1127            prepared_registry: Arc::new(PreparedRegistry::new()),
1128            oauth_validator: None,
1129        };
1130
1131        let open = reddb_wire::replication::WalStreamOpen {
1132            since_lsn: 0,
1133            max_count: 1,
1134            replica_id: Some("replica-a".to_string()),
1135            term: 5,
1136            await_data: false,
1137            await_timeout_ms: 1,
1138        };
1139        let mut request = Request::new(JsonPayloadRequest {
1140            payload_json: String::from_utf8(open.encode_json()).expect("json"),
1141        });
1142        request.metadata_mut().insert(
1143            "authorization",
1144            format!("Bearer {}", bootstrap.api_key.key)
1145                .parse()
1146                .expect("metadata"),
1147        );
1148
1149        let err = RedDb::pull_wal_records(&service, request)
1150            .await
1151            .expect_err("stale term should be fenced");
1152
1153        assert_eq!(err.code(), tonic::Code::FailedPrecondition);
1154        assert!(
1155            err.message().contains("stale")
1156                || err.message().contains("fenced")
1157                || err.message().contains("current term"),
1158            "unexpected stale-term error: {err:?}"
1159        );
1160    }
1161
1162    fn seed_grpc_param_table(runtime: &RedDBRuntime) {
1163        runtime
1164            .execute_query("CREATE TABLE p (id INTEGER, name TEXT)")
1165            .expect("create table");
1166        runtime
1167            .execute_query("INSERT INTO p (id, name) VALUES (1, 'Alice')")
1168            .expect("insert alice");
1169        runtime
1170            .execute_query("INSERT INTO p (id, name) VALUES (2, 'Bob')")
1171            .expect("insert bob");
1172    }
1173
1174    fn grpc_param_values() -> Vec<QueryValue> {
1175        vec![
1176            QueryValue {
1177                kind: Some(Kind::IntValue(1)),
1178            },
1179            QueryValue {
1180                kind: Some(Kind::TextValue("Alice".to_string())),
1181            },
1182        ]
1183    }
1184
1185    fn env_lock() -> &'static std::sync::Mutex<()> {
1186        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1187        LOCK.get_or_init(|| std::sync::Mutex::new(()))
1188    }
1189
1190    struct EnvGuard {
1191        previous: Vec<(&'static str, Option<String>)>,
1192    }
1193
1194    impl EnvGuard {
1195        fn set(vars: &[(&'static str, &'static str)]) -> Self {
1196            let previous = vars
1197                .iter()
1198                .map(|(key, _)| (*key, std::env::var(key).ok()))
1199                .collect();
1200            for (key, value) in vars {
1201                std::env::set_var(key, value);
1202            }
1203            Self { previous }
1204        }
1205    }
1206
1207    impl Drop for EnvGuard {
1208        fn drop(&mut self) {
1209            for (key, value) in self.previous.iter().rev() {
1210                match value {
1211                    Some(value) => std::env::set_var(key, value),
1212                    None => std::env::remove_var(key),
1213                }
1214            }
1215        }
1216    }
1217
1218    fn temp_data_path(name: &str) -> std::path::PathBuf {
1219        let suffix = SystemTime::now()
1220            .duration_since(UNIX_EPOCH)
1221            .unwrap()
1222            .as_nanos();
1223        std::env::temp_dir().join(format!("reddb_{name}_{suffix}.rdb"))
1224    }
1225
1226    fn cleanup(data_path: &std::path::Path) {
1227        let _ = std::fs::remove_file(data_path);
1228        let _ = std::fs::remove_file(
1229            crate::replication::primary::PrimaryReplication::slot_path_for(data_path),
1230        );
1231        let _ = std::fs::remove_file(crate::replication::primary::LogicalWalSpool::path_for(
1232            data_path,
1233        ));
1234        let _ = std::fs::remove_dir_all(
1235            crate::replication::primary::PrimaryReplication::primary_replica_root_for(data_path),
1236        );
1237        reddb_file::cleanup_rebootstrap_artifacts(data_path);
1238    }
1239}
1240
1241#[cfg(test)]
1242mod grpc_ask_query_reply_tests {
1243    use super::*;
1244    use crate::storage::query::unified::{UnifiedRecord, UnifiedResult};
1245    use reddb_rql::modes::QueryMode;
1246    use reddb_types::Value as SchemaValue;
1247
1248    fn ask_runtime_result() -> RuntimeQueryResult {
1249        let mut result = UnifiedResult::with_columns(vec![
1250            "answer".into(),
1251            "provider".into(),
1252            "model".into(),
1253            "mode".into(),
1254            "retry_count".into(),
1255            "prompt_tokens".into(),
1256            "completion_tokens".into(),
1257            "sources_flat".into(),
1258            "citations".into(),
1259            "validation".into(),
1260        ]);
1261        let mut record = UnifiedRecord::new();
1262        record.set("answer", SchemaValue::text("Deploy failed [^1]."));
1263        record.set("provider", SchemaValue::text("openai"));
1264        record.set("model", SchemaValue::text("gpt-4o-mini"));
1265        record.set("mode", SchemaValue::text("strict"));
1266        record.set("retry_count", SchemaValue::Integer(0));
1267        record.set("prompt_tokens", SchemaValue::Integer(11));
1268        record.set("completion_tokens", SchemaValue::Integer(7));
1269        record.set(
1270            "sources_flat",
1271            SchemaValue::Json(
1272                br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
1273            ),
1274        );
1275        record.set(
1276            "citations",
1277            SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
1278        );
1279        record.set(
1280            "validation",
1281            SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
1282        );
1283        result.push(record);
1284
1285        RuntimeQueryResult {
1286            query: "ASK 'why did deploy fail?'".to_string(),
1287            mode: QueryMode::Sql,
1288            statement: "ask",
1289            engine: "runtime-ai",
1290            result,
1291            affected_rows: 0,
1292            statement_type: "select",
1293            bookmark: None,
1294            notice: None,
1295        }
1296    }
1297
1298    #[test]
1299    fn query_reply_ask_result_json_uses_full_canonical_schema() {
1300        let reply = query_reply(ask_runtime_result(), &None, &None);
1301        let json: crate::json::Value =
1302            crate::json::from_str(&reply.result_json).expect("valid ask json");
1303
1304        assert_eq!(
1305            json.get("answer").and_then(crate::json::Value::as_str),
1306            Some("Deploy failed [^1].")
1307        );
1308        assert_eq!(
1309            json.get("cache_hit").and_then(crate::json::Value::as_bool),
1310            Some(false)
1311        );
1312        assert_eq!(
1313            json.get("cost_usd").and_then(crate::json::Value::as_f64),
1314            Some(0.0)
1315        );
1316        assert_eq!(
1317            json.get("mode").and_then(crate::json::Value::as_str),
1318            Some("strict")
1319        );
1320        assert_eq!(
1321            json.get("retry_count").and_then(crate::json::Value::as_u64),
1322            Some(0)
1323        );
1324        assert!(
1325            json.get("records").is_none(),
1326            "ASK must not be row-wrapped: {}",
1327            reply.result_json
1328        );
1329        assert!(
1330            json.get("sources_flat")
1331                .and_then(crate::json::Value::as_array)
1332                .is_some_and(|sources| sources.len() == 1
1333                    && sources[0]
1334                        .get("payload")
1335                        .and_then(crate::json::Value::as_str)
1336                        .is_some()),
1337            "sources_flat must be parsed with payload fallback: {}",
1338            reply.result_json
1339        );
1340        assert!(
1341            json.get("citations")
1342                .and_then(crate::json::Value::as_array)
1343                .is_some_and(|citations| citations.len() == 1),
1344            "citations must be parsed: {}",
1345            reply.result_json
1346        );
1347        assert_eq!(
1348            json.get("validation")
1349                .and_then(|v| v.get("ok"))
1350                .and_then(crate::json::Value::as_bool),
1351            Some(true)
1352        );
1353    }
1354
1355    #[test]
1356    fn query_reply_non_ask_answer_column_keeps_row_shape() {
1357        let mut result = UnifiedResult::with_columns(vec!["answer".into()]);
1358        let mut record = UnifiedRecord::new();
1359        record.set("answer", SchemaValue::text("plain select"));
1360        result.push(record);
1361
1362        let reply = query_reply(
1363            RuntimeQueryResult {
1364                query: "SELECT 'plain select' AS answer".to_string(),
1365                mode: QueryMode::Sql,
1366                statement: "select",
1367                engine: "runtime-sql",
1368                result,
1369                affected_rows: 0,
1370                statement_type: "select",
1371                bookmark: None,
1372                notice: None,
1373            },
1374            &None,
1375            &None,
1376        );
1377        let json: crate::json::Value =
1378            crate::json::from_str(&reply.result_json).expect("valid query json");
1379
1380        assert!(
1381            json.get("records").is_some(),
1382            "non-ASK must stay row-wrapped"
1383        );
1384        assert!(
1385            json.get("answer").is_none(),
1386            "non-ASK must not use ASK envelope"
1387        );
1388    }
1389
1390    #[test]
1391    fn query_reply_non_ask_json_column_preserves_object() {
1392        let mut result = UnifiedResult::with_columns(vec!["value".into()]);
1393        let mut record = UnifiedRecord::new();
1394        record.set(
1395            "value",
1396            SchemaValue::Json(br#"{"alpha":"A","nested":{"leaf":12}}"#.to_vec()),
1397        );
1398        result.push(record);
1399
1400        let reply = query_reply(
1401            RuntimeQueryResult {
1402                query: "LIST KV proj AS JSON".to_string(),
1403                mode: QueryMode::Sql,
1404                statement: "kv_list_json",
1405                engine: "kv",
1406                result,
1407                affected_rows: 0,
1408                statement_type: "select",
1409                bookmark: None,
1410                notice: None,
1411            },
1412            &None,
1413            &None,
1414        );
1415        let json: crate::json::Value =
1416            crate::json::from_str(&reply.result_json).expect("valid query json");
1417        let value = json
1418            .get("records")
1419            .and_then(crate::json::Value::as_array)
1420            .and_then(|records| records.first())
1421            .and_then(|record| record.get("value"))
1422            .expect("value column");
1423
1424        assert_eq!(
1425            value.get("alpha").and_then(crate::json::Value::as_str),
1426            Some("A")
1427        );
1428        assert_eq!(
1429            value
1430                .get("nested")
1431                .and_then(|nested| nested.get("leaf"))
1432                .and_then(crate::json::Value::as_f64),
1433            Some(12.0)
1434        );
1435    }
1436}
1437
1438/// Emit a single info-level event with the SHA-256 fingerprint of the
1439/// active gRPC server cert + an mTLS flag. Never logs PEM bytes.
1440fn log_grpc_tls_identity(tls: &GrpcTlsOptions) {
1441    use sha2::{Digest, Sha256};
1442    let cert_fp = {
1443        let mut h = Sha256::new();
1444        h.update(&tls.cert_pem);
1445        let digest = h.finalize();
1446        // First 16 hex chars are enough for human cross-check; the full
1447        // SHA-256 lives in audit logs only.
1448        let mut buf = String::with_capacity(64);
1449        for b in digest.iter() {
1450            buf.push_str(&format!("{b:02x}"));
1451        }
1452        buf
1453    };
1454    tracing::info!(
1455        target: "reddb::security",
1456        transport = "grpc",
1457        cert_sha256 = %cert_fp,
1458        mtls = tls.client_ca_pem.is_some(),
1459        "gRPC TLS identity loaded"
1460    );
1461}
1462
1463include!("grpc/service_impl.rs");