Skip to main content

mongreldb_client/
lib.rs

1//! mongreldb-client — a lightweight HTTP client for `mongreldb-server`.
2//!
3//! Hardened surface:
4//! - Every call checks the HTTP status and returns a typed [`ClientError`] on
5//!   non-2xx (the server's `KitErrorEnvelope` is decoded into
6//!   [`KitError::unique_violation`]/`fk_violation`/etc.).
7//! - Typed [`KitTxnRequest`]/[`KitTxnResponse`] models mirror the server's
8//!   `/kit/txn` batch endpoint.
9//! - [`MongrelClient::kit_schema`] fetches table metadata (columns +
10//!   constraints).
11//! - Legacy SQL read ([`MongrelClient::sql`]) returns Arrow IPC batches.
12
13pub mod native;
14
15use std::io::{Cursor, Read};
16
17use arrow::ipc::reader::FileReader;
18use arrow::record_batch::RecordBatch;
19use secrecy::ExposeSecret;
20pub use secrecy::SecretString;
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23use zeroize::Zeroizing;
24
25/// A typed client error. Network/IO failures and non-2xx HTTP responses both
26/// surface here; constraint violations from `/kit/txn` are decoded into the
27/// matching variant so callers can branch on them.
28#[derive(Debug, Error)]
29pub enum ClientError {
30    #[error("io/transport error: {0}")]
31    Transport(String),
32    #[error("http {status}: {body}")]
33    Http { status: u16, body: String },
34    #[error("decode error: {0}")]
35    Decode(String),
36    #[error("kit error: {code}: {message}")]
37    Kit {
38        code: KitErrorCode,
39        message: String,
40        op_index: Option<usize>,
41        status: u16,
42        committed: Option<bool>,
43        epoch: Option<u64>,
44        epoch_text: Option<String>,
45        retryable: Option<bool>,
46    },
47    #[error("remote query {code}: {message}")]
48    Query {
49        status: u16,
50        code: RemoteQueryErrorCode,
51        message: String,
52        response: Box<RemoteQueryErrorResponse>,
53    },
54    #[error("query {query_id} outcome unknown after transport loss: {message}")]
55    QueryOutcomeUnknown {
56        query_id: String,
57        message: String,
58        status: Option<Box<RemoteQueryStatus>>,
59        cancel_outcome: Option<RemoteCancelOutcome>,
60    },
61    #[error("native RPC {code}: {category}: {message}")]
62    Native {
63        code: String,
64        category_code: Option<u32>,
65        category: String,
66        message: String,
67        retryable: bool,
68    },
69}
70
71/// Typed error codes mirrored from the server's Kit error envelope.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum KitErrorCode {
74    UniqueViolation,
75    FkViolation,
76    CheckViolation,
77    ProcedureNotFound,
78    ProcedureValidation,
79    ProcedureExecution,
80    TriggerNotFound,
81    TriggerValidation,
82    Conflict,
83    AuthRequired,
84    PermissionDenied,
85    DeadlineExceeded,
86    WorkBudgetExceeded,
87    Cancelled,
88    CommitOutcome,
89    QueryOutcomeUnknown,
90    IdempotencyKeyReuseMismatch,
91    IdempotencyStoreFull,
92    IdempotencyStoreUnavailable,
93    InvalidIdempotencyKey,
94    BadRequest,
95    NotFound,
96    Internal,
97    Other,
98}
99
100impl std::fmt::Display for KitErrorCode {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106impl KitErrorCode {
107    fn as_str(&self) -> &'static str {
108        match self {
109            KitErrorCode::UniqueViolation => "UNIQUE_VIOLATION",
110            KitErrorCode::FkViolation => "FK_VIOLATION",
111            KitErrorCode::CheckViolation => "CHECK_VIOLATION",
112            KitErrorCode::ProcedureNotFound => "PROCEDURE_NOT_FOUND",
113            KitErrorCode::ProcedureValidation => "PROCEDURE_VALIDATION",
114            KitErrorCode::ProcedureExecution => "PROCEDURE_EXECUTION",
115            KitErrorCode::TriggerNotFound => "TRIGGER_NOT_FOUND",
116            KitErrorCode::TriggerValidation => "TRIGGER_VALIDATION",
117            KitErrorCode::Conflict => "CONFLICT",
118            KitErrorCode::AuthRequired => "AUTH_REQUIRED",
119            KitErrorCode::PermissionDenied => "PERMISSION_DENIED",
120            KitErrorCode::DeadlineExceeded => "DEADLINE_EXCEEDED",
121            KitErrorCode::WorkBudgetExceeded => "WORK_BUDGET_EXCEEDED",
122            KitErrorCode::Cancelled => "CANCELLED",
123            KitErrorCode::CommitOutcome => "COMMIT_OUTCOME",
124            KitErrorCode::QueryOutcomeUnknown => "QUERY_OUTCOME_UNKNOWN",
125            KitErrorCode::IdempotencyKeyReuseMismatch => "IDEMPOTENCY_KEY_REUSE_MISMATCH",
126            KitErrorCode::IdempotencyStoreFull => "IDEMPOTENCY_STORE_FULL",
127            KitErrorCode::IdempotencyStoreUnavailable => "IDEMPOTENCY_STORE_UNAVAILABLE",
128            KitErrorCode::InvalidIdempotencyKey => "INVALID_IDEMPOTENCY_KEY",
129            KitErrorCode::BadRequest => "BAD_REQUEST",
130            KitErrorCode::NotFound => "NOT_FOUND",
131            KitErrorCode::Internal => "INTERNAL",
132            KitErrorCode::Other => "OTHER",
133        }
134    }
135
136    fn from_str(s: &str) -> Self {
137        match s {
138            "UNIQUE_VIOLATION" => KitErrorCode::UniqueViolation,
139            "FK_VIOLATION" => KitErrorCode::FkViolation,
140            "CHECK_VIOLATION" => KitErrorCode::CheckViolation,
141            "PROCEDURE_NOT_FOUND" => KitErrorCode::ProcedureNotFound,
142            "PROCEDURE_VALIDATION" => KitErrorCode::ProcedureValidation,
143            "PROCEDURE_EXECUTION" => KitErrorCode::ProcedureExecution,
144            "TRIGGER_NOT_FOUND" => KitErrorCode::TriggerNotFound,
145            "TRIGGER_VALIDATION" => KitErrorCode::TriggerValidation,
146            "CONFLICT" => KitErrorCode::Conflict,
147            "AUTH_REQUIRED" => KitErrorCode::AuthRequired,
148            "PERMISSION_DENIED" => KitErrorCode::PermissionDenied,
149            "DEADLINE_EXCEEDED" => KitErrorCode::DeadlineExceeded,
150            "WORK_BUDGET_EXCEEDED" => KitErrorCode::WorkBudgetExceeded,
151            "CANCELLED" => KitErrorCode::Cancelled,
152            "COMMIT_OUTCOME" => KitErrorCode::CommitOutcome,
153            "QUERY_OUTCOME_UNKNOWN" => KitErrorCode::QueryOutcomeUnknown,
154            "IDEMPOTENCY_KEY_REUSE_MISMATCH" => KitErrorCode::IdempotencyKeyReuseMismatch,
155            "IDEMPOTENCY_STORE_FULL" => KitErrorCode::IdempotencyStoreFull,
156            "IDEMPOTENCY_STORE_UNAVAILABLE" => KitErrorCode::IdempotencyStoreUnavailable,
157            "INVALID_IDEMPOTENCY_KEY" => KitErrorCode::InvalidIdempotencyKey,
158            "BAD_REQUEST" => KitErrorCode::BadRequest,
159            "NOT_FOUND" => KitErrorCode::NotFound,
160            "INTERNAL" => KitErrorCode::Internal,
161            _ => KitErrorCode::Other,
162        }
163    }
164}
165
166impl From<reqwest::Error> for ClientError {
167    fn from(e: reqwest::Error) -> Self {
168        ClientError::Transport(e.to_string())
169    }
170}
171
172impl From<std::io::Error> for ClientError {
173    fn from(e: std::io::Error) -> Self {
174        ClientError::Transport(e.to_string())
175    }
176}
177
178pub type ClientResult<T> = std::result::Result<T, ClientError>;
179
180const MAX_CONTROL_RESPONSE_BYTES: u64 = 1024 * 1024;
181// Server output-byte limits cover result data, not the enclosing JSON/Arrow
182// protocol metadata. Keep bounded headroom for a valid 64 MiB result.
183const MAX_SQL_RESPONSE_BYTES: u64 = 65 * 1024 * 1024;
184const MAX_REPLICATION_WAL_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
185const MAX_REPLICATION_SNAPSHOT_BYTES: u64 = 512 * 1024 * 1024;
186
187struct StrictJsonValue(serde_json::Value);
188
189impl<'de> Deserialize<'de> for StrictJsonValue {
190    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191    where
192        D: serde::Deserializer<'de>,
193    {
194        struct Visitor;
195
196        impl<'de> serde::de::Visitor<'de> for Visitor {
197            type Value = StrictJsonValue;
198
199            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200                formatter.write_str("a JSON value without duplicate object keys")
201            }
202
203            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
204                Ok(StrictJsonValue(value.into()))
205            }
206
207            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
208                Ok(StrictJsonValue(value.into()))
209            }
210
211            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
212                Ok(StrictJsonValue(value.into()))
213            }
214
215            fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
216            where
217                E: serde::de::Error,
218            {
219                serde_json::Number::from_f64(value)
220                    .map(serde_json::Value::Number)
221                    .map(StrictJsonValue)
222                    .ok_or_else(|| E::custom("non-finite JSON number"))
223            }
224
225            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
226                Ok(StrictJsonValue(value.into()))
227            }
228
229            fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
230                Ok(StrictJsonValue(value.into()))
231            }
232
233            fn visit_none<E>(self) -> Result<Self::Value, E> {
234                Ok(StrictJsonValue(serde_json::Value::Null))
235            }
236
237            fn visit_unit<E>(self) -> Result<Self::Value, E> {
238                Ok(StrictJsonValue(serde_json::Value::Null))
239            }
240
241            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
242            where
243                D: serde::Deserializer<'de>,
244            {
245                <StrictJsonValue as Deserialize>::deserialize(deserializer)
246            }
247
248            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
249            where
250                A: serde::de::SeqAccess<'de>,
251            {
252                let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0));
253                while let Some(value) = sequence.next_element::<StrictJsonValue>()? {
254                    values.push(value.0);
255                }
256                Ok(StrictJsonValue(serde_json::Value::Array(values)))
257            }
258
259            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
260            where
261                A: serde::de::MapAccess<'de>,
262            {
263                let mut values = serde_json::Map::with_capacity(map.size_hint().unwrap_or(0));
264                while let Some(key) = map.next_key::<String>()? {
265                    if values.contains_key(&key) {
266                        return Err(serde::de::Error::custom(format!(
267                            "duplicate JSON object key {key:?}"
268                        )));
269                    }
270                    let value = map.next_value::<StrictJsonValue>()?;
271                    values.insert(key, value.0);
272                }
273                Ok(StrictJsonValue(serde_json::Value::Object(values)))
274            }
275        }
276
277        deserializer.deserialize_any(Visitor)
278    }
279}
280
281fn strict_json_value(bytes: &[u8]) -> Result<serde_json::Value, String> {
282    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
283    let value =
284        StrictJsonValue::deserialize(&mut deserializer).map_err(|error| error.to_string())?;
285    deserializer.end().map_err(|error| error.to_string())?;
286    Ok(value.0)
287}
288
289fn strict_json<T: serde::de::DeserializeOwned>(bytes: &[u8], context: &str) -> ClientResult<T> {
290    let value = strict_json_value(bytes)
291        .map_err(|error| ClientError::Decode(format!("invalid {context} response: {error}")))?;
292    serde_json::from_value(value)
293        .map_err(|error| ClientError::Decode(format!("invalid {context} response: {error}")))
294}
295
296fn strict_roundtrip_json<T>(bytes: &[u8], context: &str) -> ClientResult<T>
297where
298    T: serde::de::DeserializeOwned + Serialize,
299{
300    let value = strict_json_value(bytes)
301        .map_err(|error| ClientError::Decode(format!("invalid {context}: {error}")))?;
302    let parsed: T = serde_json::from_value(value.clone())
303        .map_err(|error| ClientError::Decode(format!("invalid {context}: {error}")))?;
304    if serde_json::to_value(&parsed)
305        .map_err(|error| ClientError::Decode(format!("invalid {context}: {error}")))?
306        != value
307    {
308        return Err(ClientError::Decode(format!(
309            "invalid {context}: unknown or non-canonical fields"
310        )));
311    }
312    Ok(parsed)
313}
314
315fn bounded_blocking_bytes(
316    mut response: reqwest::blocking::Response,
317    limit: u64,
318) -> Result<Vec<u8>, String> {
319    if response
320        .content_length()
321        .is_some_and(|length| length > limit)
322    {
323        return Err(format!("response exceeds {limit} bytes"));
324    }
325    let mut bytes = Vec::new();
326    response
327        .by_ref()
328        .take(limit.saturating_add(1))
329        .read_to_end(&mut bytes)
330        .map_err(|error| error.to_string())?;
331    if bytes.len() as u64 > limit {
332        return Err(format!("response exceeds {limit} bytes"));
333    }
334    Ok(bytes)
335}
336
337async fn bounded_async_bytes(
338    mut response: reqwest::Response,
339    limit: u64,
340) -> Result<Vec<u8>, String> {
341    if response
342        .content_length()
343        .is_some_and(|length| length > limit)
344    {
345        return Err(format!("response exceeds {limit} bytes"));
346    }
347    let mut bytes = Vec::new();
348    while let Some(chunk) = response.chunk().await.map_err(|error| error.to_string())? {
349        if (bytes.len() as u64).saturating_add(chunk.len() as u64) > limit {
350            return Err(format!("response exceeds {limit} bytes"));
351        }
352        bytes.extend_from_slice(&chunk);
353    }
354    Ok(bytes)
355}
356
357fn decode_blocking_json<T: serde::de::DeserializeOwned>(
358    response: reqwest::blocking::Response,
359    limit: u64,
360    context: &str,
361) -> ClientResult<T> {
362    let bytes = bounded_blocking_bytes(response, limit)
363        .map_err(|error| ClientError::Decode(format!("invalid {context} response: {error}")))?;
364    strict_json(&bytes, context)
365}
366
367async fn decode_async_json<T: serde::de::DeserializeOwned>(
368    response: reqwest::Response,
369    limit: u64,
370    context: &str,
371) -> ClientResult<T> {
372    let bytes = bounded_async_bytes(response, limit)
373        .await
374        .map_err(|error| ClientError::Decode(format!("invalid {context} response: {error}")))?;
375    strict_json(&bytes, context)
376}
377
378#[derive(Clone)]
379pub enum RemoteAuth {
380    Bearer(SecretString),
381    Basic {
382        username: String,
383        password: SecretString,
384    },
385}
386
387impl std::fmt::Debug for RemoteAuth {
388    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        match self {
390            Self::Bearer(_) => formatter.write_str("Bearer([REDACTED])"),
391            Self::Basic { username, .. } => formatter
392                .debug_struct("Basic")
393                .field("username", username)
394                .field("password", &"[REDACTED]")
395                .finish(),
396        }
397    }
398}
399
400#[derive(Debug, Clone, Default)]
401pub struct RemoteOptions {
402    pub auth: Option<RemoteAuth>,
403    pub transport_timeout: Option<std::time::Duration>,
404}
405
406#[derive(Clone)]
407pub struct MongrelClient {
408    base_url: String,
409    client: reqwest::blocking::Client,
410    transport: ClientTransportOptions,
411}
412
413#[derive(Clone, Copy, Default)]
414struct ClientTransportOptions {
415    connect_timeout: Option<std::time::Duration>,
416    request_timeout: Option<std::time::Duration>,
417    pool_idle_timeout: Option<std::time::Duration>,
418}
419
420/// Builder for a blocking [`MongrelClient`]. Authorization is installed as a
421/// sensitive default header, so every route uses it and debug output redacts it.
422pub struct MongrelClientBuilder {
423    base_url: String,
424    invalid_base_url: bool,
425    authorization: Option<reqwest::header::HeaderValue>,
426    invalid_authorization: bool,
427    connect_timeout: Option<std::time::Duration>,
428    request_timeout: Option<std::time::Duration>,
429    pool_idle_timeout: Option<std::time::Duration>,
430}
431
432/// Builder for an [`AsyncMongrelClient`].
433pub struct AsyncMongrelClientBuilder {
434    base_url: String,
435    invalid_base_url: bool,
436    authorization: Option<reqwest::header::HeaderValue>,
437    invalid_authorization: bool,
438    connect_timeout: Option<std::time::Duration>,
439    request_timeout: Option<std::time::Duration>,
440    pool_idle_timeout: Option<std::time::Duration>,
441}
442
443fn bearer_header(token: &str) -> ClientResult<reqwest::header::HeaderValue> {
444    let value = Zeroizing::new(format!("Bearer {token}"));
445    sensitive_header(value.as_str())
446}
447
448fn basic_header(username: &str, password: &str) -> ClientResult<reqwest::header::HeaderValue> {
449    let credentials = Zeroizing::new(format!("{username}:{password}"));
450    let encoded = Zeroizing::new(base64::Engine::encode(
451        &base64::engine::general_purpose::STANDARD,
452        credentials.as_bytes(),
453    ));
454    let value = Zeroizing::new(format!("Basic {}", encoded.as_str()));
455    sensitive_header(value.as_str())
456}
457
458fn sensitive_header(value: &str) -> ClientResult<reqwest::header::HeaderValue> {
459    let mut value = reqwest::header::HeaderValue::from_str(value)
460        .map_err(|_| ClientError::Transport("invalid authorization credentials".into()))?;
461    value.set_sensitive(true);
462    Ok(value)
463}
464
465fn default_headers(
466    authorization: Option<reqwest::header::HeaderValue>,
467) -> reqwest::header::HeaderMap {
468    let mut headers = reqwest::header::HeaderMap::new();
469    if let Some(value) = authorization {
470        headers.insert(reqwest::header::AUTHORIZATION, value);
471    }
472    headers
473}
474
475fn url_with_segments(base_url: &str, segments: &[&str]) -> ClientResult<String> {
476    let mut url = reqwest::Url::parse(base_url)
477        .map_err(|_| ClientError::Transport("invalid client base URL".into()))?;
478    {
479        let mut path = url
480            .path_segments_mut()
481            .map_err(|_| ClientError::Transport("client base URL cannot contain a path".into()))?;
482        path.pop_if_empty();
483        path.extend(segments.iter().copied());
484    }
485    Ok(url.into())
486}
487
488fn blocking_http_client(
489    authorization: Option<reqwest::header::HeaderValue>,
490    transport: ClientTransportOptions,
491) -> ClientResult<reqwest::blocking::Client> {
492    let mut builder =
493        reqwest::blocking::Client::builder().default_headers(default_headers(authorization));
494    if let Some(timeout) = transport.connect_timeout {
495        builder = builder.connect_timeout(timeout);
496    }
497    if let Some(timeout) = transport.request_timeout {
498        builder = builder.timeout(timeout);
499    }
500    if let Some(timeout) = transport.pool_idle_timeout {
501        builder = builder.pool_idle_timeout(timeout);
502    }
503    Ok(builder.build()?)
504}
505
506fn async_http_client(
507    authorization: Option<reqwest::header::HeaderValue>,
508    transport: ClientTransportOptions,
509) -> ClientResult<reqwest::Client> {
510    let mut builder = reqwest::Client::builder().default_headers(default_headers(authorization));
511    if let Some(timeout) = transport.connect_timeout {
512        builder = builder.connect_timeout(timeout);
513    }
514    if let Some(timeout) = transport.request_timeout {
515        builder = builder.timeout(timeout);
516    }
517    if let Some(timeout) = transport.pool_idle_timeout {
518        builder = builder.pool_idle_timeout(timeout);
519    }
520    Ok(builder.build()?)
521}
522
523fn sanitized_base_url(value: &str) -> Option<String> {
524    let url = reqwest::Url::parse(value).ok()?;
525    if !matches!(url.scheme(), "http" | "https")
526        || url.host_str().is_none()
527        || !url.username().is_empty()
528        || url.password().is_some()
529        || url.query().is_some()
530        || url.fragment().is_some()
531    {
532        return None;
533    }
534    Some(url.as_str().trim_end_matches('/').to_owned())
535}
536
537impl MongrelClientBuilder {
538    pub fn connect_timeout(mut self, timeout: std::time::Duration) -> Self {
539        self.connect_timeout = Some(timeout);
540        self
541    }
542
543    pub fn request_timeout(mut self, timeout: std::time::Duration) -> Self {
544        self.request_timeout = Some(timeout);
545        self
546    }
547
548    pub fn pool_idle_timeout(mut self, timeout: std::time::Duration) -> Self {
549        self.pool_idle_timeout = Some(timeout);
550        self
551    }
552
553    pub fn bearer_token(mut self, token: impl AsRef<str>) -> Self {
554        match bearer_header(token.as_ref()) {
555            Ok(value) => self.authorization = Some(value),
556            Err(_) => self.invalid_authorization = true,
557        }
558        self
559    }
560
561    pub fn basic_auth(mut self, username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
562        match basic_header(username.as_ref(), password.as_ref()) {
563            Ok(value) => self.authorization = Some(value),
564            Err(_) => self.invalid_authorization = true,
565        }
566        self
567    }
568
569    pub fn build(self) -> ClientResult<MongrelClient> {
570        if self.invalid_base_url {
571            return Err(ClientError::Transport(
572                "invalid MongrelDB server URL; use HTTP(S) without credentials, query, or fragment"
573                    .into(),
574            ));
575        }
576        if self.invalid_authorization {
577            return Err(ClientError::Transport(
578                "invalid authorization credentials".into(),
579            ));
580        }
581        let transport = ClientTransportOptions {
582            connect_timeout: self.connect_timeout,
583            request_timeout: self.request_timeout,
584            pool_idle_timeout: self.pool_idle_timeout,
585        };
586        let client = blocking_http_client(self.authorization, transport)?;
587        Ok(MongrelClient {
588            base_url: self.base_url,
589            client,
590            transport,
591        })
592    }
593}
594
595impl AsyncMongrelClientBuilder {
596    pub fn connect_timeout(mut self, timeout: std::time::Duration) -> Self {
597        self.connect_timeout = Some(timeout);
598        self
599    }
600
601    pub fn request_timeout(mut self, timeout: std::time::Duration) -> Self {
602        self.request_timeout = Some(timeout);
603        self
604    }
605
606    pub fn pool_idle_timeout(mut self, timeout: std::time::Duration) -> Self {
607        self.pool_idle_timeout = Some(timeout);
608        self
609    }
610
611    pub fn bearer_token(mut self, token: impl AsRef<str>) -> Self {
612        match bearer_header(token.as_ref()) {
613            Ok(value) => self.authorization = Some(value),
614            Err(_) => self.invalid_authorization = true,
615        }
616        self
617    }
618
619    pub fn basic_auth(mut self, username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
620        match basic_header(username.as_ref(), password.as_ref()) {
621            Ok(value) => self.authorization = Some(value),
622            Err(_) => self.invalid_authorization = true,
623        }
624        self
625    }
626
627    pub fn build(self) -> ClientResult<AsyncMongrelClient> {
628        if self.invalid_base_url {
629            return Err(ClientError::Transport(
630                "invalid MongrelDB server URL; use HTTP(S) without credentials, query, or fragment"
631                    .into(),
632            ));
633        }
634        if self.invalid_authorization {
635            return Err(ClientError::Transport(
636                "invalid authorization credentials".into(),
637            ));
638        }
639        let transport = ClientTransportOptions {
640            connect_timeout: self.connect_timeout,
641            request_timeout: self.request_timeout,
642            pool_idle_timeout: self.pool_idle_timeout,
643        };
644        let client = async_http_client(self.authorization, transport)?;
645        Ok(AsyncMongrelClient {
646            base_url: self.base_url,
647            client,
648            transport,
649        })
650    }
651}
652
653impl MongrelClient {
654    pub fn try_with_bearer_token(mut self, token: impl AsRef<str>) -> ClientResult<Self> {
655        self.client = blocking_http_client(Some(bearer_header(token.as_ref())?), self.transport)?;
656        Ok(self)
657    }
658
659    pub fn with_bearer_token(self, token: impl AsRef<str>) -> ClientResult<Self> {
660        self.try_with_bearer_token(token)
661    }
662
663    pub fn try_with_basic_auth(
664        mut self,
665        username: impl AsRef<str>,
666        password: impl AsRef<str>,
667    ) -> ClientResult<Self> {
668        self.client = blocking_http_client(
669            Some(basic_header(username.as_ref(), password.as_ref())?),
670            self.transport,
671        )?;
672        Ok(self)
673    }
674
675    pub fn with_basic_auth(
676        self,
677        username: impl AsRef<str>,
678        password: impl AsRef<str>,
679    ) -> ClientResult<Self> {
680        self.try_with_basic_auth(username, password)
681    }
682}
683
684/// Async counterpart for Kit and health calls.
685#[derive(Clone)]
686pub struct AsyncMongrelClient {
687    base_url: String,
688    client: reqwest::Client,
689    transport: ClientTransportOptions,
690}
691
692const SQL_RECOVERY_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
693const SQL_RECOVERY_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
694const SQL_RECOVERY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
695
696#[derive(Debug, Clone, Default)]
697pub struct SqlClientOptions {
698    pub query_id: Option<mongreldb_query::QueryId>,
699    pub timeout: Option<std::time::Duration>,
700}
701
702pub type RemoteSqlControlOptions = SqlClientOptions;
703
704#[derive(Debug, Clone)]
705pub struct SqlPageOptions {
706    pub query_id: Option<mongreldb_query::QueryId>,
707    pub timeout: Option<std::time::Duration>,
708    pub max_output_rows: Option<u64>,
709    pub max_output_bytes: Option<u64>,
710    pub page_size_rows: u64,
711    pub projection: Vec<String>,
712    pub max_page_bytes: Option<u64>,
713    pub max_page_tokens: Option<u64>,
714}
715
716impl SqlPageOptions {
717    pub fn new(page_size_rows: u64, projection: Vec<String>) -> Self {
718        Self {
719            query_id: None,
720            timeout: None,
721            max_output_rows: None,
722            max_output_bytes: None,
723            page_size_rows,
724            projection,
725            max_page_bytes: None,
726            max_page_tokens: None,
727        }
728    }
729}
730
731fn validate_sql_page_options(options: &SqlPageOptions) -> ClientResult<()> {
732    if options.page_size_rows == 0
733        || options.max_output_rows == Some(0)
734        || options.max_output_bytes == Some(0)
735        || options.max_page_bytes == Some(0)
736        || options.max_page_tokens == Some(0)
737    {
738        return Err(ClientError::Decode(
739            "SQL pagination row, byte, and token limits must be positive".into(),
740        ));
741    }
742    if options.projection.is_empty() || options.projection.len() > 128 {
743        return Err(ClientError::Decode(
744            "SQL pagination projection must contain 1 to 128 columns".into(),
745        ));
746    }
747    let mut seen = std::collections::HashSet::new();
748    let projection_bytes = options
749        .projection
750        .iter()
751        .map(String::len)
752        .fold(0usize, usize::saturating_add);
753    if projection_bytes > 16 * 1024
754        || options.projection.iter().any(|column| {
755            column.is_empty()
756                || column == "*"
757                || column.len() > 256
758                || !seen.insert(column.as_str())
759        })
760    {
761        return Err(ClientError::Decode(
762            "SQL pagination projection requires unique explicit column names of at most 256 bytes"
763                .into(),
764        ));
765    }
766    Ok(())
767}
768
769fn validate_remote_sql_page(
770    page: RemoteSqlPage,
771    initial_options: Option<&SqlPageOptions>,
772) -> Result<RemoteSqlPage, String> {
773    let metadata = &page.page;
774    let end = metadata
775        .offset
776        .checked_add(metadata.row_count)
777        .ok_or_else(|| "SQL page offset overflowed".to_owned())?;
778    if page.status != "completed" {
779        return Err("SQL page status is not completed".into());
780    }
781    if metadata.row_count != page.rows.len() {
782        return Err("SQL page row_count does not match rows".into());
783    }
784    if page.rows.iter().any(|row| !row.is_object()) {
785        return Err("SQL page rows must be JSON objects".into());
786    }
787    if metadata.projection.is_empty()
788        || metadata.projection.iter().any(|column| column.is_empty())
789        || metadata
790            .projection
791            .iter()
792            .collect::<std::collections::HashSet<_>>()
793            .len()
794            != metadata.projection.len()
795        || page.rows.iter().any(|row| match row.as_object() {
796            Some(object) => {
797                object.len() != metadata.projection.len()
798                    || metadata
799                        .projection
800                        .iter()
801                        .any(|column| !object.contains_key(column))
802            }
803            None => true,
804        })
805    {
806        return Err("SQL page rows do not exactly match the projection".into());
807    }
808    let byte_count = page.rows.iter().try_fold(2_usize, |bytes, row| {
809        serde_json::to_vec(row)
810            .map(|encoded| {
811                bytes
812                    .saturating_add(usize::from(bytes > 2))
813                    .saturating_add(encoded.len())
814            })
815            .map_err(|error| error.to_string())
816    })?;
817    if metadata.byte_count != byte_count
818        || metadata.estimated_tokens != byte_count.saturating_add(3) / 4
819    {
820        return Err("SQL page byte or token estimate is invalid".into());
821    }
822    if metadata.offset > metadata.total_rows || end > metadata.total_rows {
823        return Err("SQL page offset or row_count exceeds total_rows".into());
824    }
825    if metadata.limits.rows == 0
826        || metadata.limits.bytes == 0
827        || metadata.limits.tokens == 0
828        || metadata.row_count > metadata.limits.rows
829        || metadata.byte_count > metadata.limits.bytes
830        || metadata.estimated_tokens > metadata.limits.tokens
831    {
832        return Err("SQL page exceeds its declared limits".into());
833    }
834    if metadata.expires_at_ms == 0
835        || metadata.snapshot != "retained_result"
836        || metadata.token_estimate != "ceil(projected_json_bytes/4)"
837    {
838        return Err("SQL page metadata is invalid".into());
839    }
840    let has_more = end < metadata.total_rows;
841    if (has_more && metadata.row_count == 0)
842        || has_more != page.next_cursor.is_some()
843        || page
844            .next_cursor
845            .as_ref()
846            .is_some_and(|cursor| cursor.is_empty() || cursor.len() > 2_048)
847    {
848        return Err("SQL page continuation cursor is inconsistent".into());
849    }
850    if let Some(options) = initial_options {
851        if metadata.offset != 0
852            || metadata.projection != options.projection
853            || metadata.limits.rows as u64 > options.page_size_rows
854            || options
855                .max_page_bytes
856                .is_some_and(|limit| metadata.limits.bytes as u64 > limit)
857            || options
858                .max_page_tokens
859                .is_some_and(|limit| metadata.limits.tokens as u64 > limit)
860            || options
861                .max_output_rows
862                .is_some_and(|limit| metadata.total_rows as u64 > limit)
863            || options
864                .max_output_bytes
865                .is_some_and(|limit| metadata.byte_count as u64 > limit)
866        {
867            return Err("SQL page does not match the requested pagination options".into());
868        }
869    }
870    Ok(page)
871}
872
873#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
874#[serde(rename_all = "snake_case")]
875pub enum RemoteCancelOutcome {
876    Accepted,
877    AlreadyCancelling,
878    TooLate,
879    AlreadyFinished,
880    NotFound,
881    PreCancelled,
882}
883
884fn cancel_outcome_from_wire(value: Option<&str>) -> Option<RemoteCancelOutcome> {
885    match value {
886        Some("accepted" | "cancellation_requested") => Some(RemoteCancelOutcome::Accepted),
887        Some("already_cancelling" | "cancelling") => Some(RemoteCancelOutcome::AlreadyCancelling),
888        Some("too_late" | "commit_critical") => Some(RemoteCancelOutcome::TooLate),
889        Some("already_finished" | "finished") => Some(RemoteCancelOutcome::AlreadyFinished),
890        Some("pre_cancelled") => Some(RemoteCancelOutcome::PreCancelled),
891        Some("not_found") => Some(RemoteCancelOutcome::NotFound),
892        _ => None,
893    }
894}
895
896fn decode_cancel_outcome(
897    body: &serde_json::Value,
898    expected_query_id: mongreldb_query::QueryId,
899    http_status: u16,
900) -> ClientResult<RemoteCancelOutcome> {
901    const FIELDS: &[&str] = &[
902        "query_id",
903        "status",
904        "terminal_state",
905        "state",
906        "server_state",
907        "code",
908        "operation",
909        "committed",
910        "committed_statements",
911        "last_commit_epoch",
912        "last_commit_epoch_text",
913        "first_commit_statement_index",
914        "last_commit_statement_index",
915        "completed_statements",
916        "statement_index",
917        "cancel_outcome",
918        "cancellation_reason",
919        "retryable",
920        "outcome",
921        "terminal_error",
922        "error",
923        "trace",
924        "started_ms_ago",
925        "deadline_ms_remaining",
926        "session_id",
927    ];
928    let object = body
929        .as_object()
930        .ok_or_else(|| ClientError::Decode("cancellation response is not an object".into()))?;
931    if let Some(field) = object
932        .keys()
933        .find(|field| !FIELDS.contains(&field.as_str()))
934    {
935        return Err(ClientError::Decode(format!(
936            "cancellation response contains unknown field {field:?}"
937        )));
938    }
939    if let Some(outcome) = object.get("outcome") {
940        serde_json::from_value::<RemoteQueryOutcome>(outcome.clone()).map_err(|error| {
941            ClientError::Decode(format!("invalid cancellation outcome: {error}"))
942        })?;
943    }
944    if let Some(error) = object.get("error") {
945        serde_json::from_value::<RemoteQueryErrorBody>(error.clone())
946            .map_err(|error| ClientError::Decode(format!("invalid cancellation error: {error}")))?;
947    }
948    if let Some(error) = object
949        .get("terminal_error")
950        .filter(|value| !value.is_null())
951    {
952        serde_json::from_value::<RemoteTerminalError>(error.clone()).map_err(|error| {
953            ClientError::Decode(format!("invalid cancellation terminal error: {error}"))
954        })?;
955    }
956    if body.get("query_id").and_then(serde_json::Value::as_str)
957        != Some(expected_query_id.to_string().as_str())
958    {
959        return Err(ClientError::Decode(
960            "cancellation response query_id does not match the request".into(),
961        ));
962    }
963    let outcome = cancel_outcome_from_wire(
964        body.get("cancel_outcome")
965            .and_then(serde_json::Value::as_str),
966    );
967    let state = cancel_outcome_from_wire(body.get("state").and_then(serde_json::Value::as_str));
968    if outcome.is_some() && state.is_some() && outcome != state {
969        return Err(ClientError::Decode(
970            "cancellation response state and cancel_outcome disagree".into(),
971        ));
972    }
973    let outcome = outcome
974        .or(state)
975        .ok_or_else(|| ClientError::Decode("cancellation response has no valid outcome".into()))?;
976    let compatible = matches!(
977        (http_status, outcome),
978        (
979            202,
980            RemoteCancelOutcome::Accepted | RemoteCancelOutcome::PreCancelled
981        ) | (
982            200,
983            RemoteCancelOutcome::AlreadyCancelling | RemoteCancelOutcome::AlreadyFinished
984        ) | (409, RemoteCancelOutcome::TooLate)
985            | (404, RemoteCancelOutcome::NotFound)
986    );
987    if !compatible {
988        return Err(ClientError::Decode(
989            "cancellation HTTP status and outcome disagree".into(),
990        ));
991    }
992    Ok(outcome)
993}
994
995#[derive(Debug, Clone, PartialEq, Eq)]
996pub enum RemoteQueryErrorCode {
997    QueryCancelled,
998    DeadlineExceeded,
999    QueryIdConflict,
1000    QueryRegistryFull,
1001    CancelTooLate,
1002    QueryAlreadyFinished,
1003    QueryNotFound,
1004    TransactionAborted,
1005    NoSqlTransaction,
1006    SavepointNotFound,
1007    CommitOutcome,
1008    QueryFailed,
1009    QueryCancelledAfterCommit,
1010    DeadlineAfterCommit,
1011    ResultLimitExceeded,
1012    SerializationFailed,
1013    SerializationFailedAfterCommit,
1014    SerializationWorkerFailed,
1015    CapabilityUnsupported,
1016    QueryOutcomeUnknown,
1017    InvalidQueryOptions,
1018    IncompatibleSqlControls,
1019    InvalidIdempotencyKey,
1020    IdempotencyKeyReuseMismatch,
1021    IdempotencyRequiresJson,
1022    IdempotencyRequiresSingleWrite,
1023    IdempotencyStoreFull,
1024    IdempotencyUnsupportedInTransaction,
1025    IdempotencyStoreUnavailable,
1026    PaginationRequiresJson,
1027    PaginationRequiresSingleReadQuery,
1028    InvalidPaginationOptions,
1029    InvalidSqlCursor,
1030    SqlCursorExpired,
1031    SqlCursorNotFound,
1032    InvalidSqlProjection,
1033    InvalidPageOffset,
1034    SqlPageStoreFull,
1035    SqlAdmissionClosed,
1036    EntropyUnavailable,
1037    Other(String),
1038}
1039
1040impl RemoteQueryErrorCode {
1041    pub fn as_str(&self) -> &str {
1042        match self {
1043            Self::QueryCancelled => "QUERY_CANCELLED",
1044            Self::DeadlineExceeded => "DEADLINE_EXCEEDED",
1045            Self::QueryIdConflict => "QUERY_ID_CONFLICT",
1046            Self::QueryRegistryFull => "QUERY_REGISTRY_FULL",
1047            Self::CancelTooLate => "CANCEL_TOO_LATE",
1048            Self::QueryAlreadyFinished => "QUERY_ALREADY_FINISHED",
1049            Self::QueryNotFound => "QUERY_NOT_FOUND",
1050            Self::TransactionAborted => "TRANSACTION_ABORTED",
1051            Self::NoSqlTransaction => "NO_SQL_TRANSACTION",
1052            Self::SavepointNotFound => "SAVEPOINT_NOT_FOUND",
1053            Self::CommitOutcome => "COMMIT_OUTCOME",
1054            Self::QueryFailed => "QUERY_FAILED",
1055            Self::QueryCancelledAfterCommit => "QUERY_CANCELLED_AFTER_COMMIT",
1056            Self::DeadlineAfterCommit => "DEADLINE_AFTER_COMMIT",
1057            Self::ResultLimitExceeded => "RESULT_LIMIT_EXCEEDED",
1058            Self::SerializationFailed => "SERIALIZATION_FAILED",
1059            Self::SerializationFailedAfterCommit => "SERIALIZATION_FAILED_AFTER_COMMIT",
1060            Self::SerializationWorkerFailed => "SERIALIZATION_WORKER_FAILED",
1061            Self::CapabilityUnsupported => "CAPABILITY_UNSUPPORTED",
1062            Self::QueryOutcomeUnknown => "QUERY_OUTCOME_UNKNOWN",
1063            Self::InvalidQueryOptions => "INVALID_QUERY_OPTIONS",
1064            Self::IncompatibleSqlControls => "INCOMPATIBLE_SQL_CONTROLS",
1065            Self::InvalidIdempotencyKey => "INVALID_IDEMPOTENCY_KEY",
1066            Self::IdempotencyKeyReuseMismatch => "IDEMPOTENCY_KEY_REUSE_MISMATCH",
1067            Self::IdempotencyRequiresJson => "IDEMPOTENCY_REQUIRES_JSON",
1068            Self::IdempotencyRequiresSingleWrite => "IDEMPOTENCY_REQUIRES_SINGLE_WRITE",
1069            Self::IdempotencyStoreFull => "IDEMPOTENCY_STORE_FULL",
1070            Self::IdempotencyUnsupportedInTransaction => "IDEMPOTENCY_UNSUPPORTED_IN_TRANSACTION",
1071            Self::IdempotencyStoreUnavailable => "IDEMPOTENCY_STORE_UNAVAILABLE",
1072            Self::PaginationRequiresJson => "PAGINATION_REQUIRES_JSON",
1073            Self::PaginationRequiresSingleReadQuery => "PAGINATION_REQUIRES_SINGLE_READ_QUERY",
1074            Self::InvalidPaginationOptions => "INVALID_PAGINATION_OPTIONS",
1075            Self::InvalidSqlCursor => "INVALID_SQL_CURSOR",
1076            Self::SqlCursorExpired => "SQL_CURSOR_EXPIRED",
1077            Self::SqlCursorNotFound => "SQL_CURSOR_NOT_FOUND",
1078            Self::InvalidSqlProjection => "INVALID_SQL_PROJECTION",
1079            Self::InvalidPageOffset => "INVALID_PAGE_OFFSET",
1080            Self::SqlPageStoreFull => "SQL_PAGE_STORE_FULL",
1081            Self::SqlAdmissionClosed => "SQL_ADMISSION_CLOSED",
1082            Self::EntropyUnavailable => "ENTROPY_UNAVAILABLE",
1083            Self::Other(code) => code,
1084        }
1085    }
1086
1087    fn from_code(code: &str) -> Self {
1088        match code {
1089            "QUERY_CANCELLED" => Self::QueryCancelled,
1090            "DEADLINE_EXCEEDED" => Self::DeadlineExceeded,
1091            "QUERY_ID_CONFLICT" => Self::QueryIdConflict,
1092            "QUERY_REGISTRY_FULL" => Self::QueryRegistryFull,
1093            "CANCEL_TOO_LATE" => Self::CancelTooLate,
1094            "QUERY_ALREADY_FINISHED" => Self::QueryAlreadyFinished,
1095            "QUERY_NOT_FOUND" => Self::QueryNotFound,
1096            "TRANSACTION_ABORTED" => Self::TransactionAborted,
1097            "NO_SQL_TRANSACTION" => Self::NoSqlTransaction,
1098            "SAVEPOINT_NOT_FOUND" => Self::SavepointNotFound,
1099            "COMMIT_OUTCOME" => Self::CommitOutcome,
1100            "QUERY_FAILED" => Self::QueryFailed,
1101            "QUERY_CANCELLED_AFTER_COMMIT" => Self::QueryCancelledAfterCommit,
1102            "DEADLINE_AFTER_COMMIT" => Self::DeadlineAfterCommit,
1103            "RESULT_LIMIT_EXCEEDED" => Self::ResultLimitExceeded,
1104            "SERIALIZATION_FAILED" => Self::SerializationFailed,
1105            "SERIALIZATION_FAILED_AFTER_COMMIT" => Self::SerializationFailedAfterCommit,
1106            "SERIALIZATION_WORKER_FAILED" => Self::SerializationWorkerFailed,
1107            "CAPABILITY_UNSUPPORTED" => Self::CapabilityUnsupported,
1108            "QUERY_OUTCOME_UNKNOWN" => Self::QueryOutcomeUnknown,
1109            "INVALID_QUERY_OPTIONS" => Self::InvalidQueryOptions,
1110            "INCOMPATIBLE_SQL_CONTROLS" => Self::IncompatibleSqlControls,
1111            "INVALID_IDEMPOTENCY_KEY" => Self::InvalidIdempotencyKey,
1112            "IDEMPOTENCY_KEY_REUSE_MISMATCH" => Self::IdempotencyKeyReuseMismatch,
1113            "IDEMPOTENCY_REQUIRES_JSON" => Self::IdempotencyRequiresJson,
1114            "IDEMPOTENCY_REQUIRES_SINGLE_WRITE" => Self::IdempotencyRequiresSingleWrite,
1115            "IDEMPOTENCY_STORE_FULL" => Self::IdempotencyStoreFull,
1116            "IDEMPOTENCY_UNSUPPORTED_IN_TRANSACTION" => Self::IdempotencyUnsupportedInTransaction,
1117            "IDEMPOTENCY_STORE_UNAVAILABLE" => Self::IdempotencyStoreUnavailable,
1118            "PAGINATION_REQUIRES_JSON" => Self::PaginationRequiresJson,
1119            "PAGINATION_REQUIRES_SINGLE_READ_QUERY" => Self::PaginationRequiresSingleReadQuery,
1120            "INVALID_PAGINATION_OPTIONS" => Self::InvalidPaginationOptions,
1121            "INVALID_SQL_CURSOR" => Self::InvalidSqlCursor,
1122            "SQL_CURSOR_EXPIRED" => Self::SqlCursorExpired,
1123            "SQL_CURSOR_NOT_FOUND" => Self::SqlCursorNotFound,
1124            "INVALID_SQL_PROJECTION" => Self::InvalidSqlProjection,
1125            "INVALID_PAGE_OFFSET" => Self::InvalidPageOffset,
1126            "SQL_PAGE_STORE_FULL" => Self::SqlPageStoreFull,
1127            "SQL_ADMISSION_CLOSED" => Self::SqlAdmissionClosed,
1128            "ENTROPY_UNAVAILABLE" => Self::EntropyUnavailable,
1129            other => Self::Other(other.into()),
1130        }
1131    }
1132}
1133
1134impl std::fmt::Display for RemoteQueryErrorCode {
1135    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1136        formatter.write_str(self.as_str())
1137    }
1138}
1139
1140impl<'de> Deserialize<'de> for RemoteQueryErrorCode {
1141    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1142    where
1143        D: serde::Deserializer<'de>,
1144    {
1145        let code = String::deserialize(deserializer)?;
1146        Ok(Self::from_code(&code))
1147    }
1148}
1149
1150/// Structural HLC from HTTP durable recovery (0.64+).
1151#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
1152#[serde(deny_unknown_fields)]
1153pub struct RemoteCommitHlc {
1154    pub physical_micros: u64,
1155    #[serde(default)]
1156    pub logical: u32,
1157    #[serde(default)]
1158    pub node_tiebreaker: u32,
1159}
1160
1161#[derive(Debug, Clone, Default, Deserialize)]
1162#[serde(deny_unknown_fields)]
1163pub struct RemoteQueryOutcome {
1164    #[serde(default)]
1165    pub committed: Option<bool>,
1166    #[serde(default)]
1167    pub committed_statements: Option<usize>,
1168    #[serde(default)]
1169    pub last_commit_epoch: Option<u64>,
1170    #[serde(default)]
1171    pub last_commit_epoch_text: Option<String>,
1172    /// Authoritative commit HLC when the server recorded one (0.64+).
1173    #[serde(default)]
1174    pub last_commit_hlc: Option<RemoteCommitHlc>,
1175    #[serde(default)]
1176    pub first_commit_statement_index: Option<usize>,
1177    #[serde(default)]
1178    pub last_commit_statement_index: Option<usize>,
1179    #[serde(default)]
1180    pub completed_statements: Option<usize>,
1181    #[serde(default)]
1182    pub statement_index: Option<usize>,
1183    #[serde(default)]
1184    pub serialization: String,
1185    /// Alias of `serialization` for native DurableOutcome field name parity.
1186    #[serde(default)]
1187    pub serialization_state: Option<String>,
1188    #[serde(default)]
1189    pub terminal_state: Option<String>,
1190}
1191
1192#[derive(Debug, Clone, Deserialize)]
1193#[serde(deny_unknown_fields)]
1194pub struct RemoteTerminalError {
1195    pub code: RemoteQueryErrorCode,
1196    pub category: String,
1197}
1198
1199#[derive(Debug, Clone, Deserialize)]
1200#[serde(deny_unknown_fields)]
1201pub struct RemoteQueryErrorBody {
1202    pub code: RemoteQueryErrorCode,
1203    pub message: String,
1204    #[serde(default)]
1205    pub query_id: Option<String>,
1206    #[serde(default)]
1207    pub committed: Option<bool>,
1208    #[serde(default)]
1209    pub retryable: bool,
1210}
1211
1212#[derive(Debug, Clone, Deserialize)]
1213#[serde(deny_unknown_fields)]
1214pub struct RemoteQueryErrorResponse {
1215    #[serde(default)]
1216    pub query_id: Option<String>,
1217    #[serde(default)]
1218    pub status: String,
1219    #[serde(default)]
1220    pub terminal_state: Option<String>,
1221    #[serde(default)]
1222    pub committed: Option<bool>,
1223    #[serde(default)]
1224    pub committed_statements: Option<usize>,
1225    #[serde(default)]
1226    pub last_commit_epoch: Option<u64>,
1227    #[serde(default)]
1228    pub last_commit_epoch_text: Option<String>,
1229    #[serde(default)]
1230    pub first_commit_statement_index: Option<usize>,
1231    #[serde(default)]
1232    pub last_commit_statement_index: Option<usize>,
1233    #[serde(default)]
1234    pub completed_statements: Option<usize>,
1235    #[serde(default)]
1236    pub statement_index: Option<usize>,
1237    #[serde(default)]
1238    pub cancel_outcome: Option<RemoteCancelOutcome>,
1239    #[serde(default)]
1240    pub cancellation_reason: Option<String>,
1241    #[serde(default)]
1242    pub retryable: bool,
1243    #[serde(default)]
1244    pub server_state: Option<String>,
1245    #[serde(default)]
1246    pub outcome: RemoteQueryOutcome,
1247    pub error: RemoteQueryErrorBody,
1248}
1249
1250#[derive(Debug, Clone, Deserialize)]
1251#[serde(deny_unknown_fields)]
1252pub struct SqlCancellationCapabilities {
1253    pub version: u8,
1254    pub client_query_ids: bool,
1255    pub cancel_endpoint: bool,
1256    pub query_status: bool,
1257    #[serde(default)]
1258    pub pre_registration_cancel: bool,
1259    pub stream_disconnect_cancels: bool,
1260}
1261
1262#[derive(Debug, Clone, Deserialize)]
1263#[serde(deny_unknown_fields)]
1264pub struct SqlIdempotencyCapabilities {
1265    pub version: u8,
1266    pub durable_pre_execution_intent: bool,
1267    pub replay_committed_receipt: bool,
1268    pub indeterminate_never_reexecutes: bool,
1269}
1270
1271#[derive(Debug, Clone, Deserialize)]
1272#[serde(deny_unknown_fields)]
1273pub struct SqlPaginationCapabilities {
1274    pub version: u8,
1275    pub continuation_endpoint: String,
1276    pub retained_snapshot: bool,
1277    pub projection_required: bool,
1278    pub byte_and_token_hints: bool,
1279}
1280
1281#[derive(Debug, Clone, Deserialize)]
1282#[serde(deny_unknown_fields)]
1283pub struct ServerCapabilities {
1284    pub sql_cancellation: SqlCancellationCapabilities,
1285    #[serde(default)]
1286    pub sql_idempotency: Option<SqlIdempotencyCapabilities>,
1287    #[serde(default)]
1288    pub sql_pagination: Option<SqlPaginationCapabilities>,
1289}
1290
1291#[derive(Debug, Clone, Deserialize)]
1292#[serde(deny_unknown_fields)]
1293pub struct RemoteQueryStatus {
1294    pub query_id: String,
1295    #[serde(default)]
1296    pub detail: Option<String>,
1297    #[serde(default)]
1298    pub status: String,
1299    #[serde(default)]
1300    pub state: String,
1301    #[serde(default)]
1302    pub server_state: String,
1303    #[serde(default)]
1304    pub terminal_state: Option<String>,
1305    #[serde(default)]
1306    pub operation: String,
1307    #[serde(default)]
1308    pub started_ms_ago: Option<u64>,
1309    #[serde(default)]
1310    pub deadline_ms_remaining: Option<u64>,
1311    #[serde(default)]
1312    pub session_id: Option<String>,
1313    #[serde(default)]
1314    pub code: Option<RemoteQueryErrorCode>,
1315    #[serde(default)]
1316    pub committed: Option<bool>,
1317    #[serde(default)]
1318    pub cancellation_reason: String,
1319    #[serde(default)]
1320    pub committed_statements: Option<usize>,
1321    #[serde(default)]
1322    pub last_commit_epoch: Option<u64>,
1323    #[serde(default)]
1324    pub last_commit_epoch_text: Option<String>,
1325    #[serde(default)]
1326    pub last_commit_hlc: Option<RemoteCommitHlc>,
1327    #[serde(default)]
1328    pub first_commit_statement_index: Option<usize>,
1329    #[serde(default)]
1330    pub last_commit_statement_index: Option<usize>,
1331    #[serde(default)]
1332    pub completed_statements: Option<usize>,
1333    #[serde(default)]
1334    pub statement_index: Option<usize>,
1335    #[serde(default)]
1336    pub cancel_outcome: Option<RemoteCancelOutcome>,
1337    #[serde(default)]
1338    pub retryable: bool,
1339    #[serde(default)]
1340    pub outcome: RemoteQueryOutcome,
1341    /// Nested durable recovery object (mirrors `outcome` with full field set).
1342    #[serde(default)]
1343    pub durable: Option<RemoteQueryOutcome>,
1344    #[serde(default)]
1345    pub terminal_error: Option<RemoteTerminalError>,
1346    #[serde(default)]
1347    pub trace: serde_json::Value,
1348}
1349
1350impl RemoteQueryStatus {
1351    pub fn is_terminal(&self) -> bool {
1352        matches!(
1353            self.server_state_or_state(),
1354            "completed" | "failed" | "cancelled" | "pre_cancelled" | "finished"
1355        )
1356    }
1357
1358    pub fn durably_committed(&self) -> Option<bool> {
1359        match (self.committed, self.outcome.committed) {
1360            (Some(true), _) | (_, Some(true)) => Some(true),
1361            (Some(false), _) | (_, Some(false)) => Some(false),
1362            (None, None) => None,
1363        }
1364    }
1365
1366    /// Prefer nested durable / outcome HLC, then top-level status field.
1367    pub fn last_commit_hlc(&self) -> Option<&RemoteCommitHlc> {
1368        self.durable
1369            .as_ref()
1370            .and_then(|d| d.last_commit_hlc.as_ref())
1371            .or(self.outcome.last_commit_hlc.as_ref())
1372            .or(self.last_commit_hlc.as_ref())
1373    }
1374
1375    pub fn server_state_or_state(&self) -> &str {
1376        if self.server_state.is_empty() {
1377            &self.state
1378        } else {
1379            &self.server_state
1380        }
1381    }
1382}
1383
1384#[derive(Debug, Clone, Deserialize)]
1385#[serde(deny_unknown_fields)]
1386pub struct RemoteSqlReceipt {
1387    pub query_id: String,
1388    pub original_query_id: String,
1389    pub status: String,
1390    #[serde(default)]
1391    pub terminal_state: Option<String>,
1392    #[serde(default)]
1393    pub server_state: String,
1394    #[serde(default)]
1395    pub cancel_outcome: Option<RemoteCancelOutcome>,
1396    #[serde(default)]
1397    pub cancellation_reason: String,
1398    pub committed: bool,
1399    pub committed_statements: usize,
1400    pub last_commit_epoch: Option<u64>,
1401    #[serde(default)]
1402    pub last_commit_epoch_text: Option<String>,
1403    #[serde(default)]
1404    pub first_commit_statement_index: Option<usize>,
1405    #[serde(default)]
1406    pub last_commit_statement_index: Option<usize>,
1407    #[serde(default)]
1408    pub completed_statements: usize,
1409    #[serde(default)]
1410    pub statement_index: usize,
1411    pub retryable: bool,
1412    pub idempotency_replayed: bool,
1413    pub idempotency_persisted: bool,
1414    pub idempotency_expires_at_ms: u64,
1415    pub outcome: RemoteQueryOutcome,
1416    #[serde(default)]
1417    pub terminal_error: Option<RemoteTerminalError>,
1418    #[serde(default)]
1419    pub commit_receipt: Option<RemoteCommitReceipt>,
1420}
1421
1422#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
1423#[serde(deny_unknown_fields)]
1424pub struct RemoteCommitReceipt {
1425    pub transaction_id: String,
1426    pub commit_ts_physical_micros: u64,
1427    pub commit_ts_logical: u32,
1428    pub commit_ts_node_tiebreaker: u32,
1429    pub log_term: u64,
1430    pub log_index: u64,
1431    pub durability: String,
1432}
1433
1434fn exact_epoch(text: Option<&str>, numeric: Option<u64>) -> Result<Option<u64>, String> {
1435    match text {
1436        Some(text) => {
1437            let epoch = text
1438                .parse::<u64>()
1439                .map_err(|_| "last_commit_epoch_text is not an unsigned integer".to_owned())?;
1440            if epoch.to_string() != text {
1441                return Err("last_commit_epoch_text is not canonical".into());
1442            }
1443            if numeric.is_some_and(|numeric| numeric != epoch) {
1444                return Err("last_commit_epoch and last_commit_epoch_text disagree".into());
1445            }
1446            Ok(Some(epoch))
1447        }
1448        None => Ok(numeric),
1449    }
1450}
1451
1452fn validate_remote_query_status(
1453    mut status: RemoteQueryStatus,
1454    expected_query_id: mongreldb_query::QueryId,
1455) -> Result<RemoteQueryStatus, String> {
1456    const STATES: &[&str] = &[
1457        "queued",
1458        "planning",
1459        "executing",
1460        "streaming",
1461        "serializing",
1462        "commit_critical",
1463        "cancelling",
1464        "completed",
1465        "failed",
1466        "cancelled",
1467        "pre_cancelled",
1468        "finished",
1469    ];
1470    const STATUSES: &[&str] = &[
1471        "running",
1472        "outcome_unknown",
1473        "completed",
1474        "failed_before_commit",
1475        "cancelled_before_commit",
1476        "deadline_before_commit",
1477        "cancelled_before_start",
1478        "committed",
1479        "committed_with_error",
1480        "partially_committed",
1481        "cancelled_after_commit",
1482        "deadline_after_commit",
1483        "finished",
1484    ];
1485    if status.query_id != expected_query_id.to_string() {
1486        return Err("query status query_id does not match the request".into());
1487    }
1488    if status
1489        .detail
1490        .as_deref()
1491        .is_some_and(|detail| detail != "compact")
1492    {
1493        return Err("query status detail is invalid".into());
1494    }
1495    if !STATUSES.contains(&status.status.as_str())
1496        || status.state.is_empty()
1497        || !STATES.contains(&status.state.as_str())
1498        || (!status.server_state.is_empty()
1499            && (!STATES.contains(&status.server_state.as_str())
1500                || status.server_state != status.state))
1501        || status
1502            .terminal_state
1503            .as_ref()
1504            .is_some_and(|terminal| terminal != &status.status)
1505    {
1506        return Err("query status state or status is invalid".into());
1507    }
1508    let state_matches_status = match status.status.as_str() {
1509        "running" => matches!(
1510            status.state.as_str(),
1511            "queued"
1512                | "planning"
1513                | "executing"
1514                | "streaming"
1515                | "serializing"
1516                | "commit_critical"
1517                | "cancelling"
1518        ),
1519        "committed" => !matches!(
1520            status.state.as_str(),
1521            "failed" | "cancelled" | "pre_cancelled" | "finished"
1522        ),
1523        "completed" => status.state == "completed",
1524        "failed_before_commit"
1525        | "committed_with_error"
1526        | "partially_committed"
1527        | "outcome_unknown" => status.state == "failed",
1528        "cancelled_before_commit"
1529        | "deadline_before_commit"
1530        | "cancelled_after_commit"
1531        | "deadline_after_commit" => status.state == "cancelled",
1532        "cancelled_before_start" => status.state == "pre_cancelled",
1533        "finished" => status.state == "finished",
1534        _ => false,
1535    };
1536    if !state_matches_status {
1537        return Err("query status state and status disagree".into());
1538    }
1539    let top_epoch = exact_epoch(
1540        status.last_commit_epoch_text.as_deref(),
1541        status.last_commit_epoch,
1542    )?;
1543    let outcome_epoch = exact_epoch(
1544        status.outcome.last_commit_epoch_text.as_deref(),
1545        status.outcome.last_commit_epoch,
1546    )?;
1547    if status
1548        .last_commit_epoch
1549        .is_some_and(|numeric| Some(numeric) != top_epoch)
1550        || status
1551            .outcome
1552            .last_commit_epoch
1553            .is_some_and(|numeric| Some(numeric) != outcome_epoch)
1554        || top_epoch != outcome_epoch
1555        || status.committed != status.outcome.committed
1556        || status.committed_statements != status.outcome.committed_statements
1557        || status.first_commit_statement_index != status.outcome.first_commit_statement_index
1558        || status.last_commit_statement_index != status.outcome.last_commit_statement_index
1559        || status.completed_statements != status.outcome.completed_statements
1560        || status.statement_index != status.outcome.statement_index
1561    {
1562        return Err("query status top-level and outcome fields disagree".into());
1563    }
1564    match status.committed {
1565        Some(true) => {
1566            if status.committed_statements == Some(0)
1567                || status.committed_statements.is_none()
1568                || top_epoch.is_none()
1569                || status.last_commit_epoch_text.is_none()
1570                || status.outcome.last_commit_epoch_text.is_none()
1571                || status.first_commit_statement_index.is_none()
1572                || status.last_commit_statement_index.is_none()
1573                || status.completed_statements.is_none()
1574                || status.statement_index.is_none()
1575                || !matches!(
1576                    status.status.as_str(),
1577                    "committed"
1578                        | "committed_with_error"
1579                        | "partially_committed"
1580                        | "cancelled_after_commit"
1581                        | "deadline_after_commit"
1582                )
1583            {
1584                return Err("committed query status has invalid durable metadata".into());
1585            }
1586        }
1587        Some(false) => {
1588            if status.committed_statements != Some(0)
1589                || top_epoch.is_some()
1590                || status.first_commit_statement_index.is_some()
1591                || status.last_commit_statement_index.is_some()
1592                || status.completed_statements.is_none()
1593                || status.statement_index.is_none()
1594                || matches!(
1595                    status.status.as_str(),
1596                    "committed"
1597                        | "committed_with_error"
1598                        | "partially_committed"
1599                        | "cancelled_after_commit"
1600                        | "deadline_after_commit"
1601                        | "outcome_unknown"
1602                        | "finished"
1603                )
1604            {
1605                return Err("non-committed query status has invalid durable metadata".into());
1606            }
1607        }
1608        None => {
1609            if status.committed_statements.is_some()
1610                || top_epoch.is_some()
1611                || status.first_commit_statement_index.is_some()
1612                || status.last_commit_statement_index.is_some()
1613                || status.completed_statements.is_some()
1614                || status.statement_index.is_some()
1615                || !matches!(status.status.as_str(), "outcome_unknown" | "finished")
1616            {
1617                return Err("unknown query status contains durable metadata".into());
1618            }
1619        }
1620    }
1621    if let (Some(first), Some(last), Some(committed)) = (
1622        status.first_commit_statement_index,
1623        status.last_commit_statement_index,
1624        status.committed_statements,
1625    ) {
1626        if first > last
1627            || committed > last.saturating_sub(first).saturating_add(1)
1628            || status
1629                .statement_index
1630                .is_some_and(|statement| last > statement)
1631        {
1632            return Err("query status commit statement indexes are invalid".into());
1633        }
1634    }
1635    if let (Some(completed), Some(statement)) =
1636        (status.completed_statements, status.statement_index)
1637    {
1638        if statement > completed || completed > statement.saturating_add(1) {
1639            return Err("query status statement index and completed count disagree".into());
1640        }
1641    }
1642    let terminal_error = status.terminal_error.as_ref();
1643    if terminal_error.is_some_and(|error| {
1644        error.code.as_str().trim().is_empty()
1645            || !matches!(
1646                error.category.as_str(),
1647                "cancellation" | "deadline" | "result_limit" | "serialization" | "execution"
1648            )
1649    }) {
1650        return Err("query status terminal error fields are invalid".into());
1651    }
1652    let terminal_error_matches = match status.status.as_str() {
1653        "running" | "completed" | "committed" | "finished" => terminal_error.is_none(),
1654        "outcome_unknown" => terminal_error.is_some_and(|error| {
1655            error.code.as_str() == "QUERY_OUTCOME_UNKNOWN" && error.category == "execution"
1656        }),
1657        "cancelled_before_start" | "cancelled_before_commit" => {
1658            terminal_error.is_some_and(|error| {
1659                error.code.as_str() == "QUERY_CANCELLED" && error.category == "cancellation"
1660            })
1661        }
1662        "cancelled_after_commit" => terminal_error.is_some_and(|error| {
1663            error.code.as_str() == "QUERY_CANCELLED_AFTER_COMMIT"
1664                && error.category == "cancellation"
1665        }),
1666        "deadline_before_commit" => terminal_error.is_some_and(|error| {
1667            error.code.as_str() == "DEADLINE_EXCEEDED" && error.category == "deadline"
1668        }),
1669        "deadline_after_commit" => terminal_error.is_some_and(|error| {
1670            error.code.as_str() == "DEADLINE_AFTER_COMMIT" && error.category == "deadline"
1671        }),
1672        "failed_before_commit" | "committed_with_error" | "partially_committed" => {
1673            terminal_error.is_some()
1674        }
1675        _ => false,
1676    };
1677    if !terminal_error_matches {
1678        return Err("query status terminal error disagrees with status".into());
1679    }
1680    if terminal_error.is_some_and(|error| {
1681        (error.category == "cancellation")
1682            != matches!(
1683                error.code.as_str(),
1684                "QUERY_CANCELLED" | "QUERY_CANCELLED_AFTER_COMMIT"
1685            )
1686            || (error.category == "deadline")
1687                != matches!(
1688                    error.code.as_str(),
1689                    "DEADLINE_EXCEEDED" | "DEADLINE_AFTER_COMMIT"
1690                )
1691    }) {
1692        return Err("query status terminal error code and category disagree".into());
1693    }
1694    let retryable = terminal_error.is_some_and(|error| {
1695        matches!(
1696            error.code.as_str(),
1697            "IDEMPOTENCY_STORE_FULL" | "IDEMPOTENCY_STORE_UNAVAILABLE"
1698        )
1699    });
1700    if status.retryable != retryable {
1701        return Err("query status retryable flag disagrees with terminal error".into());
1702    }
1703    let expected_cancel_outcome = match status.state.as_str() {
1704        "commit_critical" => Some(RemoteCancelOutcome::TooLate),
1705        "cancelling" => Some(RemoteCancelOutcome::Accepted),
1706        "completed" | "failed" | "cancelled" | "finished" => {
1707            Some(RemoteCancelOutcome::AlreadyFinished)
1708        }
1709        "pre_cancelled" => Some(RemoteCancelOutcome::PreCancelled),
1710        _ => None,
1711    };
1712    if status.cancel_outcome != expected_cancel_outcome {
1713        return Err("query status cancel_outcome disagrees with state".into());
1714    }
1715    const CANCELLATION_REASONS: &[&str] = &[
1716        "none",
1717        "client_request",
1718        "client_disconnected",
1719        "session_closed",
1720        "server_shutdown",
1721        "deadline",
1722    ];
1723    let valid_reason = match status.status.as_str() {
1724        "finished" => status.cancellation_reason == "none",
1725        "cancelled_before_start" | "cancelled_before_commit" | "cancelled_after_commit" => {
1726            CANCELLATION_REASONS.contains(&status.cancellation_reason.as_str())
1727                && !matches!(status.cancellation_reason.as_str(), "none" | "deadline")
1728        }
1729        "deadline_before_commit" | "deadline_after_commit" => {
1730            status.cancellation_reason == "deadline"
1731        }
1732        "running" | "committed" if status.state == "cancelling" => {
1733            CANCELLATION_REASONS.contains(&status.cancellation_reason.as_str())
1734                && status.cancellation_reason != "none"
1735        }
1736        _ => status.cancellation_reason == "none",
1737    };
1738    if !valid_reason {
1739        return Err("query status cancellation_reason disagrees with status".into());
1740    }
1741    let serialization = status.outcome.serialization.as_str();
1742    let valid_serialization = match status.status.as_str() {
1743        "finished" | "outcome_unknown" => serialization == "unknown",
1744        "completed" => serialization == "succeeded",
1745        "running" | "committed" => match status.state.as_str() {
1746            "serializing" => serialization == "in_progress",
1747            "cancelling" => matches!(serialization, "not_started" | "in_progress"),
1748            "completed" => serialization == "succeeded",
1749            _ => serialization == "not_started",
1750        },
1751        _ => matches!(serialization, "not_started" | "failed"),
1752    };
1753    if !valid_serialization {
1754        return Err("query status serialization state is invalid".into());
1755    }
1756    let terminal_state_valid = match status.status.as_str() {
1757        "running" | "finished" => status.terminal_state.is_none(),
1758        "committed" if status.state != "completed" => status.terminal_state.is_none(),
1759        _ => status.terminal_state.as_deref() == Some(status.status.as_str()),
1760    };
1761    if !terminal_state_valid {
1762        return Err("query status terminal_state is invalid".into());
1763    }
1764    if matches!(status.state.as_str(), "pre_cancelled" | "finished") {
1765        if !status.operation.is_empty() {
1766            return Err("synthetic query status unexpectedly names an operation".into());
1767        }
1768    } else if status.operation.is_empty() {
1769        return Err("live query status lacks operation".into());
1770    }
1771    status.last_commit_epoch = top_epoch;
1772    status.outcome.last_commit_epoch = outcome_epoch;
1773    Ok(status)
1774}
1775
1776fn validate_remote_query_error(
1777    mut response: RemoteQueryErrorResponse,
1778    expected_query_id: mongreldb_query::QueryId,
1779) -> Result<RemoteQueryErrorResponse, String> {
1780    let expected = expected_query_id.to_string();
1781    if response.query_id.as_deref() != Some(&expected)
1782        || response.error.query_id.as_deref() != Some(&expected)
1783        || response.terminal_state.as_deref() != Some(response.status.as_str())
1784        || response.error.message.is_empty()
1785    {
1786        return Err("query error query_id does not match the request".into());
1787    }
1788    if response.committed != response.outcome.committed
1789        || response.committed != response.error.committed
1790        || response.committed_statements != response.outcome.committed_statements
1791        || response.first_commit_statement_index != response.outcome.first_commit_statement_index
1792        || response.last_commit_statement_index != response.outcome.last_commit_statement_index
1793        || response.completed_statements != response.outcome.completed_statements
1794        || response.statement_index != response.outcome.statement_index
1795        || response.retryable != response.error.retryable
1796    {
1797        return Err("query error top-level, outcome, and error fields disagree".into());
1798    }
1799    let top_epoch = exact_epoch(
1800        response.last_commit_epoch_text.as_deref(),
1801        response.last_commit_epoch,
1802    )?;
1803    let outcome_epoch = exact_epoch(
1804        response.outcome.last_commit_epoch_text.as_deref(),
1805        response.outcome.last_commit_epoch,
1806    )?;
1807    if response
1808        .last_commit_epoch
1809        .is_some_and(|numeric| Some(numeric) != top_epoch)
1810        || response
1811            .outcome
1812            .last_commit_epoch
1813            .is_some_and(|numeric| Some(numeric) != outcome_epoch)
1814        || top_epoch != outcome_epoch
1815    {
1816        return Err("query error top-level and outcome commit epochs disagree".into());
1817    }
1818    let outcome_unknown = response.error.code == RemoteQueryErrorCode::QueryOutcomeUnknown;
1819    match response.committed {
1820        Some(true) => {
1821            if outcome_unknown
1822                || response.committed_statements == Some(0)
1823                || response.committed_statements.is_none()
1824                || top_epoch.is_none()
1825                || response.last_commit_epoch_text.is_none()
1826                || response.outcome.last_commit_epoch_text.is_none()
1827                || response.first_commit_statement_index.is_none()
1828                || response.last_commit_statement_index.is_none()
1829                || response.completed_statements.is_none()
1830                || response.statement_index.is_none()
1831                || !matches!(
1832                    response.status.as_str(),
1833                    "committed"
1834                        | "committed_with_error"
1835                        | "partially_committed"
1836                        | "cancelled_after_commit"
1837                        | "deadline_after_commit"
1838                )
1839            {
1840                return Err("committed query error has invalid durable metadata".into());
1841            }
1842        }
1843        Some(false) => {
1844            if outcome_unknown
1845                || response.committed_statements != Some(0)
1846                || top_epoch.is_some()
1847                || response.first_commit_statement_index.is_some()
1848                || response.last_commit_statement_index.is_some()
1849                || response.completed_statements.is_none()
1850                || response.statement_index.is_none()
1851                || !matches!(
1852                    response.status.as_str(),
1853                    "failed_before_commit"
1854                        | "cancelled_before_commit"
1855                        | "deadline_before_commit"
1856                        | "cancelled_before_start"
1857                )
1858            {
1859                return Err("non-committed query error has invalid durable metadata".into());
1860            }
1861        }
1862        None => {
1863            if !outcome_unknown
1864                || response.status != "outcome_unknown"
1865                || response.committed_statements.is_some()
1866                || top_epoch.is_some()
1867                || response.first_commit_statement_index.is_some()
1868                || response.last_commit_statement_index.is_some()
1869                || response.completed_statements.is_some()
1870                || response.statement_index.is_some()
1871                || response.retryable
1872            {
1873                return Err("unknown query error contains contradictory metadata".into());
1874            }
1875        }
1876    }
1877    if outcome_unknown && response.status != "outcome_unknown" {
1878        return Err("outcome-unknown error has the wrong status".into());
1879    }
1880    if response.retryable
1881        && (response.committed != Some(false)
1882            || !matches!(
1883                response.error.code.as_str(),
1884                "QUERY_REGISTRY_FULL" | "IDEMPOTENCY_STORE_FULL" | "IDEMPOTENCY_STORE_UNAVAILABLE"
1885            ))
1886    {
1887        return Err("query error retryable flag is unsafe".into());
1888    }
1889    if let (Some(first), Some(last), Some(committed)) = (
1890        response.first_commit_statement_index,
1891        response.last_commit_statement_index,
1892        response.committed_statements,
1893    ) {
1894        if first > last
1895            || committed > last.saturating_sub(first).saturating_add(1)
1896            || response
1897                .statement_index
1898                .is_some_and(|statement| last > statement)
1899        {
1900            return Err("query error commit statement indexes are invalid".into());
1901        }
1902    }
1903    if let (Some(completed), Some(statement)) =
1904        (response.completed_statements, response.statement_index)
1905    {
1906        if statement > completed || completed > statement.saturating_add(1) {
1907            return Err("query error statement index and completed count disagree".into());
1908        }
1909    }
1910    let code_matches = match response.error.code {
1911        RemoteQueryErrorCode::QueryOutcomeUnknown => response.status == "outcome_unknown",
1912        RemoteQueryErrorCode::QueryCancelledAfterCommit => {
1913            response.status == "cancelled_after_commit" && response.committed == Some(true)
1914        }
1915        RemoteQueryErrorCode::DeadlineAfterCommit => {
1916            response.status == "deadline_after_commit" && response.committed == Some(true)
1917        }
1918        RemoteQueryErrorCode::QueryCancelled => matches!(
1919            response.status.as_str(),
1920            "cancelled_before_commit" | "cancelled_before_start"
1921        ),
1922        RemoteQueryErrorCode::DeadlineExceeded => response.status == "deadline_before_commit",
1923        RemoteQueryErrorCode::CommitOutcome
1924        | RemoteQueryErrorCode::SerializationFailedAfterCommit => response.committed == Some(true),
1925        RemoteQueryErrorCode::SerializationFailed => response.committed == Some(false),
1926        _ => true,
1927    };
1928    if !code_matches {
1929        return Err("query error code and status disagree".into());
1930    }
1931    let status_matches_code = match response.status.as_str() {
1932        "outcome_unknown" => response.error.code == RemoteQueryErrorCode::QueryOutcomeUnknown,
1933        "cancelled_after_commit" => {
1934            response.error.code == RemoteQueryErrorCode::QueryCancelledAfterCommit
1935        }
1936        "deadline_after_commit" => response.error.code == RemoteQueryErrorCode::DeadlineAfterCommit,
1937        "cancelled_before_commit" | "cancelled_before_start" => {
1938            response.error.code == RemoteQueryErrorCode::QueryCancelled
1939        }
1940        "deadline_before_commit" => response.error.code == RemoteQueryErrorCode::DeadlineExceeded,
1941        _ => true,
1942    };
1943    if !status_matches_code {
1944        return Err("query error status and code disagree".into());
1945    }
1946    const CANCELLATION_REASONS: &[&str] = &[
1947        "none",
1948        "client_request",
1949        "client_disconnected",
1950        "session_closed",
1951        "server_shutdown",
1952        "deadline",
1953    ];
1954    let cancellation_error = matches!(
1955        response.error.code,
1956        RemoteQueryErrorCode::QueryCancelled | RemoteQueryErrorCode::QueryCancelledAfterCommit
1957    );
1958    let deadline_error = matches!(
1959        response.error.code,
1960        RemoteQueryErrorCode::DeadlineExceeded | RemoteQueryErrorCode::DeadlineAfterCommit
1961    );
1962    let expected_cancel_outcome = if cancellation_error || deadline_error {
1963        Some(RemoteCancelOutcome::Accepted)
1964    } else {
1965        match response.server_state.as_deref() {
1966            Some("commit_critical") => Some(RemoteCancelOutcome::TooLate),
1967            Some("cancelling") => Some(RemoteCancelOutcome::Accepted),
1968            Some("completed" | "failed" | "cancelled") => {
1969                Some(RemoteCancelOutcome::AlreadyFinished)
1970            }
1971            Some(_) | None => None,
1972        }
1973    };
1974    if response.cancel_outcome != expected_cancel_outcome {
1975        return Err("query error cancel_outcome disagrees with its phase".into());
1976    }
1977    let valid_reason = match response.cancellation_reason.as_deref() {
1978        Some("deadline") => deadline_error,
1979        Some(reason) if CANCELLATION_REASONS.contains(&reason) => {
1980            if cancellation_error {
1981                reason != "none"
1982            } else {
1983                response.server_state.is_some() && reason == "none"
1984            }
1985        }
1986        None => response.server_state.is_none() && !cancellation_error && !deadline_error,
1987        Some(_) => false,
1988    };
1989    if !valid_reason {
1990        return Err("query error cancellation_reason is invalid".into());
1991    }
1992    let valid_server_state = match response.server_state.as_deref() {
1993        None => true,
1994        Some("cancelled") => cancellation_error || deadline_error,
1995        Some("failed") => !cancellation_error && !deadline_error,
1996        Some(_) => false,
1997    };
1998    if !valid_server_state {
1999        return Err("query error server_state disagrees with its status".into());
2000    }
2001    let valid_serialization = match response.server_state.as_deref() {
2002        None => response.outcome.serialization == "unknown",
2003        Some(_) if outcome_unknown => response.outcome.serialization == "unknown",
2004        Some("failed" | "cancelled") => {
2005            matches!(
2006                response.outcome.serialization.as_str(),
2007                "not_started" | "failed"
2008            )
2009        }
2010        Some(_) => false,
2011    };
2012    if !valid_serialization {
2013        return Err("query error serialization state is invalid".into());
2014    }
2015    response.last_commit_epoch = top_epoch;
2016    response.outcome.last_commit_epoch = outcome_epoch;
2017    Ok(response)
2018}
2019
2020fn validate_queryless_sql_error(
2021    mut response: RemoteQueryErrorResponse,
2022) -> Result<RemoteQueryErrorResponse, String> {
2023    if response.query_id.is_some()
2024        || response.error.query_id.is_some()
2025        || response.status != "failed_before_commit"
2026        || response.terminal_state.as_deref() != Some("failed_before_commit")
2027        || response.server_state.as_deref() != Some("failed")
2028        || response.committed != Some(false)
2029        || response.error.committed != Some(false)
2030        || response.outcome.committed != Some(false)
2031        || response.committed_statements != Some(0)
2032        || response.outcome.committed_statements != Some(0)
2033        || response.last_commit_epoch.is_some()
2034        || response.last_commit_epoch_text.is_some()
2035        || response.outcome.last_commit_epoch.is_some()
2036        || response.outcome.last_commit_epoch_text.is_some()
2037        || response.first_commit_statement_index.is_some()
2038        || response.last_commit_statement_index.is_some()
2039        || response.outcome.first_commit_statement_index.is_some()
2040        || response.outcome.last_commit_statement_index.is_some()
2041        || response.completed_statements != Some(0)
2042        || response.outcome.completed_statements != Some(0)
2043        || response.statement_index != Some(0)
2044        || response.outcome.statement_index != Some(0)
2045        || response.cancel_outcome.is_some()
2046        || response.cancellation_reason.is_some()
2047        || response.retryable
2048        || response.error.retryable
2049        || response.outcome.serialization != "not_started"
2050        || response.error.message.is_empty()
2051        || !matches!(
2052            response.error.code,
2053            RemoteQueryErrorCode::InvalidSqlCursor
2054                | RemoteQueryErrorCode::SqlCursorExpired
2055                | RemoteQueryErrorCode::SqlCursorNotFound
2056                | RemoteQueryErrorCode::ResultLimitExceeded
2057                | RemoteQueryErrorCode::SerializationFailed
2058                | RemoteQueryErrorCode::SerializationWorkerFailed
2059                | RemoteQueryErrorCode::SqlAdmissionClosed
2060                | RemoteQueryErrorCode::EntropyUnavailable
2061        )
2062    {
2063        return Err("queryless SQL error metadata is invalid".into());
2064    }
2065    response.last_commit_epoch = None;
2066    response.outcome.last_commit_epoch = None;
2067    Ok(response)
2068}
2069
2070fn validate_remote_sql_receipt(
2071    mut receipt: RemoteSqlReceipt,
2072    expected_query_id: mongreldb_query::QueryId,
2073    expected_original_query_id: Option<mongreldb_query::QueryId>,
2074) -> Result<RemoteSqlReceipt, String> {
2075    if receipt.query_id != expected_query_id.to_string()
2076        || receipt.terminal_state.as_deref() != Some(receipt.status.as_str())
2077    {
2078        return Err("receipt query_id does not match the request".into());
2079    }
2080    if receipt
2081        .original_query_id
2082        .parse::<mongreldb_query::QueryId>()
2083        .is_err()
2084    {
2085        return Err("receipt original_query_id is invalid".into());
2086    }
2087    let status_committed = match receipt.status.as_str() {
2088        "completed" => false,
2089        "committed"
2090        | "committed_with_error"
2091        | "partially_committed"
2092        | "cancelled_after_commit"
2093        | "deadline_after_commit" => true,
2094        _ => return Err("receipt status is invalid".into()),
2095    };
2096    let expected_server_state = match receipt.status.as_str() {
2097        "completed" | "committed" => "completed",
2098        "committed_with_error" | "partially_committed" => "failed",
2099        "cancelled_after_commit" | "deadline_after_commit" => "cancelled",
2100        _ => return Err("receipt status is invalid".into()),
2101    };
2102    if receipt.server_state != expected_server_state
2103        || receipt.cancel_outcome != Some(RemoteCancelOutcome::AlreadyFinished)
2104    {
2105        return Err("receipt terminal state metadata is invalid".into());
2106    }
2107    let terminal_error = receipt.terminal_error.as_ref();
2108    let terminal_error_matches = match receipt.status.as_str() {
2109        "completed" | "committed" => terminal_error.is_none(),
2110        "cancelled_after_commit" => terminal_error.is_some_and(|error| {
2111            error.code.as_str() == "QUERY_CANCELLED_AFTER_COMMIT"
2112                && error.category == "cancellation"
2113        }),
2114        "deadline_after_commit" => terminal_error.is_some_and(|error| {
2115            error.code.as_str() == "DEADLINE_AFTER_COMMIT" && error.category == "deadline"
2116        }),
2117        "committed_with_error" | "partially_committed" => terminal_error.is_some(),
2118        _ => false,
2119    };
2120    if !terminal_error_matches
2121        || terminal_error.is_some_and(|error| {
2122            !matches!(
2123                error.category.as_str(),
2124                "cancellation" | "deadline" | "result_limit" | "serialization" | "execution"
2125            ) || (error.category == "cancellation")
2126                != (error.code.as_str() == "QUERY_CANCELLED_AFTER_COMMIT")
2127                || (error.category == "deadline")
2128                    != (error.code.as_str() == "DEADLINE_AFTER_COMMIT")
2129                || (error.category == "serialization")
2130                    != (error.code.as_str() == "SERIALIZATION_FAILED_AFTER_COMMIT")
2131        })
2132    {
2133        return Err("receipt terminal error disagrees with status".into());
2134    }
2135    let valid_reason = match receipt.status.as_str() {
2136        "cancelled_after_commit" => matches!(
2137            receipt.cancellation_reason.as_str(),
2138            "client_request" | "client_disconnected" | "session_closed" | "server_shutdown"
2139        ),
2140        "deadline_after_commit" => receipt.cancellation_reason == "deadline",
2141        _ => receipt.cancellation_reason == "none",
2142    };
2143    let serialization = receipt.outcome.serialization.as_str();
2144    let valid_serialization = matches!(serialization, "not_started" | "succeeded" | "failed")
2145        && match receipt.status.as_str() {
2146            "completed" | "committed" => serialization == "succeeded",
2147            _ if terminal_error.is_some_and(|error| error.category == "serialization") => {
2148                serialization == "failed"
2149            }
2150            _ => serialization != "succeeded",
2151        };
2152    if !valid_reason || !valid_serialization {
2153        return Err("receipt cancellation or serialization metadata is invalid".into());
2154    }
2155    if receipt.committed != status_committed
2156        || receipt.outcome.committed != Some(receipt.committed)
2157        || receipt.outcome.committed_statements != Some(receipt.committed_statements)
2158        || receipt.outcome.first_commit_statement_index != receipt.first_commit_statement_index
2159        || receipt.outcome.last_commit_statement_index != receipt.last_commit_statement_index
2160        || receipt.outcome.completed_statements != Some(receipt.completed_statements)
2161        || receipt.outcome.statement_index != Some(receipt.statement_index)
2162    {
2163        return Err("receipt top-level and outcome fields disagree".into());
2164    }
2165    let top_epoch = exact_epoch(
2166        receipt.last_commit_epoch_text.as_deref(),
2167        receipt.last_commit_epoch,
2168    )?;
2169    let outcome_epoch = exact_epoch(
2170        receipt.outcome.last_commit_epoch_text.as_deref(),
2171        receipt.outcome.last_commit_epoch,
2172    )?;
2173    if receipt
2174        .last_commit_epoch
2175        .is_some_and(|numeric| Some(numeric) != top_epoch)
2176        || receipt
2177            .outcome
2178            .last_commit_epoch
2179            .is_some_and(|numeric| Some(numeric) != outcome_epoch)
2180        || top_epoch != outcome_epoch
2181    {
2182        return Err("receipt top-level and outcome commit epochs disagree".into());
2183    }
2184    if receipt.committed {
2185        if receipt.committed_statements == 0
2186            || top_epoch.is_none()
2187            || receipt.last_commit_epoch_text.is_none()
2188            || receipt.outcome.last_commit_epoch_text.is_none()
2189            || receipt.first_commit_statement_index.is_none()
2190            || receipt.last_commit_statement_index.is_none()
2191        {
2192            return Err("committed receipt has no durable commit metadata".into());
2193        }
2194    } else if receipt.committed_statements != 0
2195        || top_epoch.is_some()
2196        || receipt.first_commit_statement_index.is_some()
2197        || receipt.last_commit_statement_index.is_some()
2198    {
2199        return Err("non-committed receipt contains commit metadata".into());
2200    }
2201    if let (Some(first), Some(last)) = (
2202        receipt.first_commit_statement_index,
2203        receipt.last_commit_statement_index,
2204    ) {
2205        if first > last
2206            || receipt.committed_statements > last.saturating_sub(first).saturating_add(1)
2207            || last > receipt.statement_index
2208        {
2209            return Err("receipt commit statement indexes are invalid".into());
2210        }
2211    }
2212    if receipt.statement_index > receipt.completed_statements
2213        || receipt.completed_statements > receipt.statement_index.saturating_add(1)
2214    {
2215        return Err("receipt statement index and completed count disagree".into());
2216    }
2217    let idempotency_identity_valid = match expected_original_query_id {
2218        Some(original) if receipt.idempotency_replayed => {
2219            receipt.original_query_id == original.to_string()
2220        }
2221        Some(_) => receipt.original_query_id == expected_query_id.to_string(),
2222        None if receipt.idempotency_replayed => true,
2223        None => receipt.original_query_id == expected_query_id.to_string(),
2224    };
2225    if !receipt.idempotency_persisted
2226        || receipt.idempotency_expires_at_ms == 0
2227        || receipt.retryable
2228        || !idempotency_identity_valid
2229    {
2230        return Err("receipt idempotency metadata is invalid".into());
2231    }
2232    receipt.last_commit_epoch = top_epoch;
2233    receipt.outcome.last_commit_epoch = outcome_epoch;
2234    Ok(receipt)
2235}
2236
2237#[derive(Clone)]
2238struct SqlReceiptCommitProof {
2239    epoch: u64,
2240    epoch_text: String,
2241    committed_statements: usize,
2242}
2243
2244fn sql_receipt_commit_proof(
2245    value: &serde_json::Value,
2246    expected_query_id: mongreldb_query::QueryId,
2247    expected_original_query_id: Option<mongreldb_query::QueryId>,
2248) -> Option<SqlReceiptCommitProof> {
2249    let object = value.as_object()?;
2250    let status = object.get("status")?.as_str()?;
2251    if !matches!(
2252        status,
2253        "committed"
2254            | "committed_with_error"
2255            | "partially_committed"
2256            | "cancelled_after_commit"
2257            | "deadline_after_commit"
2258    ) || object.get("query_id")?.as_str()? != expected_query_id.to_string()
2259        || !object.get("committed")?.as_bool()?
2260        || object.get("retryable")?.as_bool()?
2261        || !object.get("idempotency_persisted")?.as_bool()?
2262    {
2263        return None;
2264    }
2265    let original = object.get("original_query_id")?.as_str()?;
2266    let replayed = object.get("idempotency_replayed")?.as_bool()?;
2267    let identity_valid = match expected_original_query_id {
2268        Some(expected) if replayed => original == expected.to_string(),
2269        Some(_) => original == expected_query_id.to_string(),
2270        None if replayed => original.parse::<mongreldb_query::QueryId>().is_ok(),
2271        None => original == expected_query_id.to_string(),
2272    };
2273    if !identity_valid {
2274        return None;
2275    }
2276    let committed_statements =
2277        usize::try_from(object.get("committed_statements")?.as_u64()?).ok()?;
2278    if committed_statements == 0 {
2279        return None;
2280    }
2281    let epoch_text = object.get("last_commit_epoch_text")?.as_str()?.to_owned();
2282    let numeric_epoch = object.get("last_commit_epoch")?.as_u64();
2283    let epoch = exact_epoch(Some(&epoch_text), numeric_epoch).ok()??;
2284    if numeric_epoch.is_some_and(|numeric| numeric != epoch) {
2285        return None;
2286    }
2287    let outcome = object.get("outcome")?.as_object()?;
2288    let outcome_numeric_epoch = outcome
2289        .get("last_commit_epoch")
2290        .and_then(serde_json::Value::as_u64);
2291    let outcome_epoch = exact_epoch(
2292        outcome
2293            .get("last_commit_epoch_text")
2294            .and_then(serde_json::Value::as_str),
2295        outcome_numeric_epoch,
2296    )
2297    .ok()??;
2298    if !outcome.get("committed")?.as_bool()?
2299        || usize::try_from(outcome.get("committed_statements")?.as_u64()?).ok()?
2300            != committed_statements
2301        || outcome.get("last_commit_epoch_text")?.as_str()? != epoch_text
2302        || outcome_epoch != epoch
2303        || outcome_numeric_epoch.is_some_and(|numeric| numeric != outcome_epoch)
2304    {
2305        return None;
2306    }
2307    Some(SqlReceiptCommitProof {
2308        epoch,
2309        epoch_text,
2310        committed_statements,
2311    })
2312}
2313
2314fn committed_sql_receipt_decode_error(
2315    query_id: mongreldb_query::QueryId,
2316    proof: SqlReceiptCommitProof,
2317    message: impl Into<String>,
2318) -> ClientError {
2319    let message = message.into();
2320    let query_id = query_id.to_string();
2321    let code = RemoteQueryErrorCode::CommitOutcome;
2322    let response = RemoteQueryErrorResponse {
2323        query_id: Some(query_id.clone()),
2324        status: "committed_with_error".into(),
2325        terminal_state: Some("committed_with_error".into()),
2326        committed: Some(true),
2327        committed_statements: Some(proof.committed_statements),
2328        last_commit_epoch: Some(proof.epoch),
2329        last_commit_epoch_text: Some(proof.epoch_text.clone()),
2330        first_commit_statement_index: None,
2331        last_commit_statement_index: None,
2332        completed_statements: None,
2333        statement_index: None,
2334        cancel_outcome: Some(RemoteCancelOutcome::AlreadyFinished),
2335        cancellation_reason: Some("none".into()),
2336        retryable: false,
2337        server_state: Some("failed".into()),
2338        outcome: RemoteQueryOutcome {
2339            committed: Some(true),
2340            committed_statements: Some(proof.committed_statements),
2341            last_commit_epoch: Some(proof.epoch),
2342            last_commit_epoch_text: Some(proof.epoch_text),
2343            serialization: "unknown".into(),
2344            ..RemoteQueryOutcome::default()
2345        },
2346        error: RemoteQueryErrorBody {
2347            code: code.clone(),
2348            message: message.clone(),
2349            query_id: Some(query_id),
2350            committed: Some(true),
2351            retryable: false,
2352        },
2353    };
2354    ClientError::Query {
2355        status: 0,
2356        code,
2357        message,
2358        response: Box::new(response),
2359    }
2360}
2361
2362fn sql_error_commit_proof(
2363    value: &serde_json::Value,
2364    expected_query_id: mongreldb_query::QueryId,
2365) -> Option<SqlReceiptCommitProof> {
2366    let object = value.as_object()?;
2367    let expected = expected_query_id.to_string();
2368    if object.get("query_id")?.as_str()? != expected
2369        || !object.get("committed")?.as_bool()?
2370        || object.get("retryable")?.as_bool()?
2371        || !matches!(
2372            object.get("status")?.as_str()?,
2373            "committed"
2374                | "committed_with_error"
2375                | "partially_committed"
2376                | "cancelled_after_commit"
2377                | "deadline_after_commit"
2378        )
2379    {
2380        return None;
2381    }
2382    let committed_statements =
2383        usize::try_from(object.get("committed_statements")?.as_u64()?).ok()?;
2384    if committed_statements == 0 {
2385        return None;
2386    }
2387    let epoch_text = object.get("last_commit_epoch_text")?.as_str()?.to_owned();
2388    let numeric_epoch = object.get("last_commit_epoch")?.as_u64()?;
2389    let epoch = exact_epoch(Some(&epoch_text), Some(numeric_epoch)).ok()??;
2390    if numeric_epoch != epoch {
2391        return None;
2392    }
2393    let outcome = object.get("outcome")?.as_object()?;
2394    let outcome_epoch_text = outcome.get("last_commit_epoch_text")?.as_str()?;
2395    let outcome_numeric_epoch = outcome.get("last_commit_epoch")?.as_u64()?;
2396    let outcome_epoch =
2397        exact_epoch(Some(outcome_epoch_text), Some(outcome_numeric_epoch)).ok()??;
2398    if !outcome.get("committed")?.as_bool()?
2399        || usize::try_from(outcome.get("committed_statements")?.as_u64()?).ok()?
2400            != committed_statements
2401        || outcome_epoch_text != epoch_text
2402        || outcome_epoch != epoch
2403        || outcome_numeric_epoch != outcome_epoch
2404    {
2405        return None;
2406    }
2407    let error = object.get("error")?.as_object()?;
2408    if error.get("query_id")?.as_str()? != expected
2409        || !error.get("committed")?.as_bool()?
2410        || error.get("retryable")?.as_bool()?
2411    {
2412        return None;
2413    }
2414    Some(SqlReceiptCommitProof {
2415        epoch,
2416        epoch_text,
2417        committed_statements,
2418    })
2419}
2420
2421enum SqlReceiptDecodeError {
2422    KnownCommit(ClientError),
2423    Unknown(String),
2424}
2425
2426fn decode_remote_sql_receipt(
2427    bytes: &[u8],
2428    query_id: mongreldb_query::QueryId,
2429    expected_original_query_id: Option<mongreldb_query::QueryId>,
2430) -> Result<RemoteSqlReceipt, SqlReceiptDecodeError> {
2431    let value = strict_json_value(bytes)
2432        .map_err(|error| SqlReceiptDecodeError::Unknown(format!("invalid SQL receipt: {error}")))?;
2433    let proof = sql_receipt_commit_proof(&value, query_id, expected_original_query_id);
2434    let receipt = serde_json::from_value::<RemoteSqlReceipt>(value).map_err(|error| {
2435        proof.clone().map_or_else(
2436            || SqlReceiptDecodeError::Unknown(format!("invalid SQL receipt: {error}")),
2437            |proof| {
2438                SqlReceiptDecodeError::KnownCommit(committed_sql_receipt_decode_error(
2439                    query_id,
2440                    proof,
2441                    format!("SQL committed but its receipt was invalid: {error}"),
2442                ))
2443            },
2444        )
2445    })?;
2446    validate_remote_sql_receipt(receipt, query_id, expected_original_query_id).map_err(|error| {
2447        proof.map_or_else(
2448            || SqlReceiptDecodeError::Unknown(error.clone()),
2449            |proof| {
2450                SqlReceiptDecodeError::KnownCommit(committed_sql_receipt_decode_error(
2451                    query_id,
2452                    proof,
2453                    format!("SQL committed but its receipt was invalid: {error}"),
2454                ))
2455            },
2456        )
2457    })
2458}
2459
2460#[derive(Debug, Clone, Deserialize, PartialEq)]
2461#[serde(deny_unknown_fields)]
2462pub struct RemoteSqlPageLimits {
2463    pub rows: usize,
2464    pub bytes: usize,
2465    pub tokens: usize,
2466}
2467
2468#[derive(Debug, Clone, Deserialize, PartialEq)]
2469#[serde(deny_unknown_fields)]
2470pub struct RemoteSqlPageMetadata {
2471    pub offset: usize,
2472    pub row_count: usize,
2473    pub total_rows: usize,
2474    pub byte_count: usize,
2475    pub estimated_tokens: usize,
2476    pub limits: RemoteSqlPageLimits,
2477    pub projection: Vec<String>,
2478    pub expires_at_ms: u64,
2479    pub snapshot: String,
2480    pub token_estimate: String,
2481}
2482
2483#[derive(Debug, Clone, Deserialize, PartialEq)]
2484#[serde(deny_unknown_fields)]
2485pub struct RemoteSqlPage {
2486    pub status: String,
2487    pub rows: Vec<serde_json::Value>,
2488    pub next_cursor: Option<String>,
2489    pub page: RemoteSqlPageMetadata,
2490}
2491
2492pub struct RemoteSqlQueryHandle {
2493    query_id: mongreldb_query::QueryId,
2494    client: MongrelClient,
2495    result: std::thread::JoinHandle<ClientResult<Vec<RecordBatch>>>,
2496}
2497
2498impl RemoteSqlQueryHandle {
2499    pub fn id(&self) -> mongreldb_query::QueryId {
2500        self.query_id
2501    }
2502
2503    pub fn cancel(&self) -> ClientResult<RemoteCancelOutcome> {
2504        self.client.cancel_sql(self.query_id)
2505    }
2506
2507    pub fn status(&self) -> ClientResult<RemoteQueryStatus> {
2508        self.client.query_status(self.query_id)
2509    }
2510
2511    pub fn wait(self) -> ClientResult<Vec<RecordBatch>> {
2512        match self.result.join() {
2513            Ok(result) => result,
2514            Err(_) => Err(self
2515                .client
2516                .recover_after_transport_loss(self.query_id, "SQL worker panicked".into())),
2517        }
2518    }
2519}
2520
2521#[derive(Serialize)]
2522struct SqlReq {
2523    sql: String,
2524    #[serde(skip_serializing_if = "Option::is_none")]
2525    format: Option<&'static str>,
2526    query_id: mongreldb_query::QueryId,
2527    #[serde(skip_serializing_if = "Option::is_none")]
2528    timeout_ms: Option<u64>,
2529    #[serde(skip_serializing_if = "Option::is_none")]
2530    max_output_rows: Option<u64>,
2531    #[serde(skip_serializing_if = "Option::is_none")]
2532    max_output_bytes: Option<u64>,
2533    #[serde(skip_serializing_if = "Option::is_none")]
2534    idempotency_key: Option<String>,
2535    #[serde(skip_serializing_if = "Option::is_none")]
2536    pagination: Option<SqlPaginationReq>,
2537}
2538
2539#[derive(Debug, Clone, Serialize)]
2540struct SqlPaginationReq {
2541    page_size_rows: u64,
2542    projection: Vec<String>,
2543    #[serde(skip_serializing_if = "Option::is_none")]
2544    max_page_bytes: Option<u64>,
2545    #[serde(skip_serializing_if = "Option::is_none")]
2546    max_page_tokens: Option<u64>,
2547}
2548
2549#[derive(Deserialize)]
2550#[serde(deny_unknown_fields)]
2551struct CountResp {
2552    count: u64,
2553}
2554
2555#[derive(Deserialize)]
2556#[serde(deny_unknown_fields)]
2557struct TableIdResponse {
2558    table_id: u64,
2559    table_id_text: String,
2560}
2561
2562#[derive(Deserialize)]
2563#[serde(deny_unknown_fields)]
2564struct RowIdResponse {
2565    row_id: String,
2566}
2567
2568#[derive(Deserialize)]
2569#[serde(deny_unknown_fields)]
2570struct EpochResponse {
2571    epoch: u64,
2572    epoch_text: String,
2573}
2574
2575#[derive(Deserialize)]
2576#[serde(deny_unknown_fields)]
2577struct CommittedWriteResponse {
2578    status: String,
2579    epoch: u64,
2580    epoch_text: String,
2581}
2582
2583#[derive(Deserialize)]
2584#[serde(deny_unknown_fields)]
2585struct TriggerDropResponse {
2586    status: String,
2587    epoch: u64,
2588    epoch_text: String,
2589    dropped_trigger: mongreldb_core::StoredTrigger,
2590    resource_tables: Vec<TriggerTableBindingResponse>,
2591}
2592
2593#[derive(Deserialize)]
2594#[serde(deny_unknown_fields)]
2595struct TriggerTableBindingResponse {
2596    name: String,
2597    table_id: u64,
2598    schema_id: u64,
2599}
2600
2601#[derive(Debug, Clone, Deserialize)]
2602#[serde(deny_unknown_fields)]
2603pub struct HistoryRetention {
2604    pub history_retention_epochs: u64,
2605    pub earliest_retained_epoch: u64,
2606}
2607
2608/// Server-side schema metadata for one table (subset of the server's descriptor).
2609#[derive(Debug, Clone, Deserialize)]
2610#[serde(deny_unknown_fields)]
2611pub struct TableSchemaInfo {
2612    pub schema_id: u64,
2613    pub columns: Vec<ColumnMeta>,
2614    #[serde(default)]
2615    pub indexes: Vec<IndexMeta>,
2616    pub constraints: ConstraintMeta,
2617}
2618
2619#[derive(Debug, Clone, Deserialize)]
2620#[serde(deny_unknown_fields)]
2621pub struct IndexMeta {
2622    pub name: String,
2623    pub column_id: u16,
2624    pub kind: String,
2625    pub predicate: Option<String>,
2626    #[serde(default)]
2627    pub options: mongreldb_core::schema::IndexOptions,
2628}
2629
2630#[derive(Debug, Clone, Deserialize)]
2631#[serde(deny_unknown_fields)]
2632pub struct ColumnMeta {
2633    pub id: u16,
2634    pub name: String,
2635    pub ty: String,
2636    pub primary_key: bool,
2637    pub nullable: bool,
2638    pub auto_increment: bool,
2639    #[serde(default)]
2640    pub embedding_source: Option<mongreldb_core::EmbeddingSource>,
2641}
2642
2643#[derive(Debug, Clone, Default, Deserialize)]
2644#[serde(deny_unknown_fields)]
2645pub struct ConstraintMeta {
2646    #[serde(default)]
2647    pub uniques: Vec<serde_json::Value>,
2648    #[serde(default)]
2649    pub foreign_keys: Vec<serde_json::Value>,
2650    #[serde(default)]
2651    pub checks: Vec<serde_json::Value>,
2652}
2653
2654// ── /kit/txn typed models ───────────────────────────────────────────────────
2655
2656/// A typed atomic batch request for `/kit/txn`.
2657#[derive(Debug, Clone, Serialize)]
2658pub struct KitTxnRequest {
2659    #[serde(skip_serializing_if = "Option::is_none")]
2660    pub idempotency_key: Option<String>,
2661    pub ops: Vec<KitOp>,
2662}
2663
2664impl KitTxnRequest {
2665    pub fn new(ops: Vec<KitOp>) -> Self {
2666        Self {
2667            idempotency_key: None,
2668            ops,
2669        }
2670    }
2671    pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
2672        self.idempotency_key = Some(key.into());
2673        self
2674    }
2675}
2676
2677/// One operation in a `/kit/txn` batch.
2678#[derive(Debug, Clone, Serialize)]
2679#[serde(rename_all = "snake_case")]
2680pub enum KitOp {
2681    Put {
2682        table: String,
2683        cells: Vec<serde_json::Value>,
2684        returning: bool,
2685    },
2686    Upsert {
2687        table: String,
2688        cells: Vec<serde_json::Value>,
2689        #[serde(skip_serializing_if = "Option::is_none")]
2690        update_cells: Option<Vec<serde_json::Value>>,
2691        returning: bool,
2692    },
2693    Delete {
2694        table: String,
2695        row_id: u64,
2696    },
2697    DeleteByPk {
2698        table: String,
2699        pk: serde_json::Value,
2700    },
2701}
2702
2703impl KitOp {
2704    pub fn put(table: impl Into<String>, cells: Vec<serde_json::Value>) -> Self {
2705        KitOp::Put {
2706            table: table.into(),
2707            cells,
2708            returning: false,
2709        }
2710    }
2711    pub fn put_returning(table: impl Into<String>, cells: Vec<serde_json::Value>) -> Self {
2712        KitOp::Put {
2713            table: table.into(),
2714            cells,
2715            returning: true,
2716        }
2717    }
2718    pub fn upsert(table: impl Into<String>, cells: Vec<serde_json::Value>) -> Self {
2719        KitOp::Upsert {
2720            table: table.into(),
2721            cells,
2722            update_cells: None,
2723            returning: false,
2724        }
2725    }
2726    pub fn delete_by_pk(table: impl Into<String>, pk: serde_json::Value) -> Self {
2727        KitOp::DeleteByPk {
2728            table: table.into(),
2729            pk,
2730        }
2731    }
2732}
2733
2734/// A typed per-op result from `/kit/txn`.
2735#[derive(Debug, Clone, Deserialize)]
2736#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "kind")]
2737pub enum KitOpResult {
2738    Put {
2739        row_id: Option<String>,
2740        auto_inc: Option<i64>,
2741        #[serde(default)]
2742        row: Option<Vec<serde_json::Value>>,
2743    },
2744    Upsert {
2745        action: String,
2746        auto_inc: Option<i64>,
2747        #[serde(default)]
2748        row: Option<Vec<serde_json::Value>>,
2749    },
2750    Deleted,
2751    NotFound,
2752}
2753
2754#[derive(Debug, Clone, Deserialize)]
2755#[serde(deny_unknown_fields)]
2756pub struct KitTxnResponse {
2757    pub status: String,
2758    pub epoch: u64,
2759    #[serde(default)]
2760    pub epoch_text: Option<String>,
2761    pub results: Vec<KitOpResult>,
2762}
2763
2764fn validate_kit_txn_response(
2765    response: KitTxnResponse,
2766    request: &KitTxnRequest,
2767) -> ClientResult<KitTxnResponse> {
2768    if response.status != "committed" {
2769        return Err(ClientError::Decode(
2770            "Kit transaction success status is not committed".into(),
2771        ));
2772    }
2773    if response.epoch_text.is_none()
2774        || exact_kit_epoch(response.epoch_text.as_deref(), Some(response.epoch))
2775            .map_err(ClientError::Decode)?
2776            .is_none()
2777    {
2778        return Err(ClientError::Decode(
2779            "Kit transaction response has no exact epoch".into(),
2780        ));
2781    }
2782    validate_kit_results(&response.results, request)?;
2783    Ok(response)
2784}
2785
2786fn kit_txn_outcome_unknown(message: impl Into<String>) -> ClientError {
2787    ClientError::Kit {
2788        code: KitErrorCode::QueryOutcomeUnknown,
2789        message: message.into(),
2790        op_index: None,
2791        status: 0,
2792        committed: None,
2793        epoch: None,
2794        epoch_text: None,
2795        retryable: Some(false),
2796    }
2797}
2798
2799fn kit_txn_auth_error(status: u16) -> ClientError {
2800    let (code, message) = if status == 401 {
2801        (KitErrorCode::AuthRequired, "authentication required")
2802    } else {
2803        (KitErrorCode::PermissionDenied, "permission denied")
2804    };
2805    ClientError::Kit {
2806        code,
2807        message: message.into(),
2808        op_index: None,
2809        status,
2810        committed: Some(false),
2811        epoch: None,
2812        epoch_text: None,
2813        retryable: Some(false),
2814    }
2815}
2816
2817fn committed_kit_txn_decode_error(
2818    status: u16,
2819    epoch: u64,
2820    epoch_text: String,
2821    message: impl Into<String>,
2822) -> ClientError {
2823    ClientError::Kit {
2824        code: KitErrorCode::CommitOutcome,
2825        message: message.into(),
2826        op_index: None,
2827        status,
2828        committed: Some(true),
2829        epoch: Some(epoch),
2830        epoch_text: Some(epoch_text),
2831        retryable: Some(false),
2832    }
2833}
2834
2835fn decode_kit_txn_success(
2836    body: &[u8],
2837    request: &KitTxnRequest,
2838    status: u16,
2839) -> ClientResult<KitTxnResponse> {
2840    let value = strict_json_value(body).map_err(|error| {
2841        kit_txn_outcome_unknown(format!("invalid Kit transaction success response: {error}"))
2842    })?;
2843    let epoch = value.get("epoch").and_then(serde_json::Value::as_u64);
2844    let epoch_text = value
2845        .get("epoch_text")
2846        .and_then(serde_json::Value::as_str)
2847        .map(str::to_owned);
2848    let proven_commit = value.get("status").and_then(serde_json::Value::as_str)
2849        == Some("committed")
2850        && epoch.is_some()
2851        && epoch_text
2852            .as_deref()
2853            .is_some_and(|text| exact_kit_epoch(Some(text), epoch).ok().flatten() == epoch);
2854    let (Some(epoch), Some(epoch_text)) = (epoch, epoch_text) else {
2855        return Err(kit_txn_outcome_unknown(
2856            "Kit transaction success response does not prove a commit",
2857        ));
2858    };
2859    if !proven_commit {
2860        return Err(kit_txn_outcome_unknown(
2861            "Kit transaction success response does not prove a commit",
2862        ));
2863    }
2864    if let Err(error) = exact_json_object_fields(
2865        &value,
2866        &["status", "epoch", "epoch_text", "results"],
2867        &["status", "epoch", "epoch_text", "results"],
2868        "Kit transaction success response",
2869    ) {
2870        return Err(committed_kit_txn_decode_error(
2871            status,
2872            epoch,
2873            epoch_text,
2874            format!("transaction committed but its response was invalid: {error}"),
2875        ));
2876    }
2877    if !value["results"].is_array() {
2878        return Err(committed_kit_txn_decode_error(
2879            status,
2880            epoch,
2881            epoch_text,
2882            "transaction committed but its results were not an array",
2883        ));
2884    }
2885    let response = serde_json::from_value::<KitTxnResponse>(value).map_err(|error| {
2886        committed_kit_txn_decode_error(
2887            status,
2888            epoch,
2889            epoch_text.clone(),
2890            format!("transaction committed but its result could not be decoded: {error}"),
2891        )
2892    })?;
2893    validate_kit_txn_response(response, request).map_err(|error| {
2894        committed_kit_txn_decode_error(
2895            status,
2896            epoch,
2897            epoch_text,
2898            format!("transaction committed but its result was invalid: {error}"),
2899        )
2900    })
2901}
2902
2903fn validate_kit_results(results: &[KitOpResult], request: &KitTxnRequest) -> ClientResult<()> {
2904    if results.len() != request.ops.len() {
2905        return Err(ClientError::Decode(
2906            "Kit transaction result count does not match request".into(),
2907        ));
2908    }
2909    for (index, (operation, result)) in request.ops.iter().zip(results).enumerate() {
2910        let matches = match (operation, result) {
2911            (KitOp::Put { returning, .. }, KitOpResult::Put { row_id, row, .. }) => {
2912                row_id.is_none()
2913                    && row.is_some() == *returning
2914                    && row.as_deref().is_none_or(valid_flat_kit_cells)
2915            }
2916            (KitOp::Upsert { returning, .. }, KitOpResult::Upsert { action, row, .. }) => {
2917                matches!(action.as_str(), "inserted" | "updated" | "unchanged")
2918                    && row.is_some() == *returning
2919                    && row.as_deref().is_none_or(valid_flat_kit_cells)
2920            }
2921            (KitOp::Delete { .. }, KitOpResult::Deleted) => true,
2922            (KitOp::DeleteByPk { .. }, KitOpResult::Deleted | KitOpResult::NotFound) => true,
2923            _ => false,
2924        };
2925        if !matches {
2926            return Err(ClientError::Decode(format!(
2927                "Kit transaction result {index} does not match request operation"
2928            )));
2929        }
2930    }
2931    Ok(())
2932}
2933
2934fn valid_flat_kit_cells(cells: &[serde_json::Value]) -> bool {
2935    if !cells.len().is_multiple_of(2) {
2936        return false;
2937    }
2938    let mut column_ids = std::collections::HashSet::new();
2939    cells.chunks_exact(2).all(|pair| {
2940        pair[0]
2941            .as_u64()
2942            .and_then(|column_id| u16::try_from(column_id).ok())
2943            .is_some_and(|column_id| column_ids.insert(column_id))
2944    })
2945}
2946
2947// ── /kit/query typed models ─────────────────────────────────────────────────
2948
2949/// A native typed query (`POST /kit/query`). `conditions` are raw JSON objects
2950/// mirroring the daemon's condition variants, e.g. `{"pk": {"value": 2}}`,
2951/// `{"range": {"column_id": 2, "lo": 0, "hi": 100}}`,
2952/// `{"ann": {"column_id": 5, "query": [...], "k": 10}}`.
2953#[derive(Debug, Clone, Serialize)]
2954pub struct KitQueryRequest {
2955    pub table: String,
2956    #[serde(skip_serializing_if = "Vec::is_empty")]
2957    pub conditions: Vec<serde_json::Value>,
2958    #[serde(skip_serializing_if = "Option::is_none")]
2959    pub projection: Option<Vec<u16>>,
2960    #[serde(skip_serializing_if = "Option::is_none")]
2961    pub limit: Option<usize>,
2962    #[serde(skip_serializing_if = "Option::is_none")]
2963    pub offset: Option<usize>,
2964    #[serde(skip_serializing_if = "Option::is_none")]
2965    pub cursor: Option<String>,
2966}
2967
2968#[derive(Debug, Clone, Deserialize)]
2969#[serde(deny_unknown_fields)]
2970pub struct KitQueryResponse {
2971    pub rows: Vec<KitQueryRow>,
2972    pub truncated: bool,
2973    #[serde(default)]
2974    pub next_cursor: Option<String>,
2975}
2976
2977#[derive(Debug, Clone, Deserialize)]
2978#[serde(deny_unknown_fields)]
2979pub struct KitQueryRow {
2980    pub row_id: String,
2981    /// Flat `[col_id, val, col_id, val, …]` cells.
2982    pub cells: Vec<serde_json::Value>,
2983}
2984
2985fn validate_kit_query_response(
2986    response: KitQueryResponse,
2987    request: &KitQueryRequest,
2988) -> ClientResult<KitQueryResponse> {
2989    if response.truncated != response.next_cursor.is_some()
2990        || response
2991            .next_cursor
2992            .as_ref()
2993            .is_some_and(|cursor| cursor.is_empty() || cursor.len() > 2_048)
2994    {
2995        return Err(ClientError::Decode(
2996            "Kit query continuation cursor is inconsistent".into(),
2997        ));
2998    }
2999    let limit = request
3000        .limit
3001        .unwrap_or(mongreldb_core::query::MAX_FINAL_LIMIT);
3002    if response.rows.len() > limit {
3003        return Err(ClientError::Decode(
3004            "Kit query returned more rows than requested".into(),
3005        ));
3006    }
3007    let projection = request.projection.as_ref().map(|columns| {
3008        columns
3009            .iter()
3010            .copied()
3011            .collect::<std::collections::HashSet<_>>()
3012    });
3013    let mut row_ids = std::collections::HashSet::new();
3014    for row in &response.rows {
3015        if row
3016            .row_id
3017            .parse::<u64>()
3018            .ok()
3019            .is_none_or(|row_id| row_id.to_string() != row.row_id)
3020            || !row_ids.insert(row.row_id.as_str())
3021            || !valid_flat_kit_cells(&row.cells)
3022            || projection.as_ref().is_some_and(|projection| {
3023                row.cells.chunks_exact(2).any(|pair| {
3024                    pair[0]
3025                        .as_u64()
3026                        .and_then(|column_id| u16::try_from(column_id).ok())
3027                        .is_none_or(|column_id| !projection.contains(&column_id))
3028                })
3029            })
3030        {
3031            return Err(ClientError::Decode(
3032                "Kit query row identifier or cell layout is invalid".into(),
3033            ));
3034        }
3035    }
3036    Ok(response)
3037}
3038
3039/// Cooperative execution limits accepted by every Kit AI endpoint.
3040#[derive(Debug, Clone, Copy, Default, Serialize)]
3041pub struct AiExecutionOptions {
3042    #[serde(skip_serializing_if = "Option::is_none")]
3043    pub deadline_ms: Option<u64>,
3044    #[serde(skip_serializing_if = "Option::is_none")]
3045    pub max_work: Option<usize>,
3046}
3047
3048#[derive(Serialize)]
3049struct WithAiExecutionOptions<'a, T> {
3050    #[serde(flatten)]
3051    request: &'a T,
3052    #[serde(flatten)]
3053    options: &'a AiExecutionOptions,
3054}
3055
3056#[derive(Debug, Clone, Serialize)]
3057pub struct KitRetrieveRequest {
3058    pub table: String,
3059    pub retriever: serde_json::Value,
3060}
3061
3062#[derive(Debug, Clone, Deserialize)]
3063#[serde(deny_unknown_fields)]
3064pub struct KitRetrieveResponse {
3065    pub hits: Vec<KitRetrieverHit>,
3066}
3067
3068/// Text → embed → ANN retrieve (`POST /kit/retrieve_text`, 0.64+).
3069#[derive(Debug, Clone, Serialize)]
3070pub struct KitRetrieveTextRequest {
3071    pub table: String,
3072    pub embedding_column: u16,
3073    pub text: String,
3074    #[serde(skip_serializing_if = "Option::is_none")]
3075    pub k: Option<usize>,
3076}
3077
3078#[derive(Debug, Clone, Deserialize)]
3079#[serde(deny_unknown_fields)]
3080pub struct KitRetrieveTextResponse {
3081    pub hits: Vec<KitRetrieverHit>,
3082    pub provenance: KitTextRetrieveProvenance,
3083}
3084
3085#[derive(Debug, Clone, Deserialize)]
3086#[serde(deny_unknown_fields)]
3087pub struct KitTextRetrieveProvenance {
3088    pub embedding_column: u16,
3089    pub provider_registry_generation: u64,
3090    pub query_source_fingerprint: String,
3091    pub semantic_identity: serde_json::Value,
3092}
3093
3094#[derive(Debug, Clone, Serialize)]
3095pub struct KitAnnRerankRequest {
3096    pub table: String,
3097    pub column_id: u16,
3098    pub query: Vec<f32>,
3099    pub candidate_k: usize,
3100    pub limit: usize,
3101    pub metric: KitVectorMetric,
3102}
3103
3104#[derive(Debug, Clone, Copy, Serialize)]
3105#[serde(rename_all = "snake_case")]
3106pub enum KitVectorMetric {
3107    Cosine,
3108    DotProduct,
3109    Euclidean,
3110}
3111
3112#[derive(Debug, Clone, Deserialize)]
3113#[serde(deny_unknown_fields)]
3114pub struct KitAnnRerankResponse {
3115    pub hits: Vec<KitAnnRerankHit>,
3116}
3117
3118#[derive(Debug, Clone, Deserialize)]
3119#[serde(deny_unknown_fields)]
3120pub struct KitAnnRerankHit {
3121    pub row_id: String,
3122    pub candidate_distance: KitAnnCandidateDistance,
3123    pub exact_score: f32,
3124}
3125
3126/// Tagged ANN candidate distance from `/kit/ann_rerank`.
3127///
3128/// `kind` is `"hamming"` (BinarySign) or `"cosine"` (Dense). `value` holds the
3129/// numeric distance; Hamming values are integral, cosine is `1 - similarity`.
3130#[derive(Debug, Clone, Deserialize)]
3131#[serde(deny_unknown_fields)]
3132pub struct KitAnnCandidateDistance {
3133    pub kind: String,
3134    pub value: f64,
3135}
3136
3137#[derive(Debug, Clone, Deserialize)]
3138#[serde(deny_unknown_fields)]
3139pub struct KitRetrieverHit {
3140    pub row_id: String,
3141    pub rank: usize,
3142    pub score: KitScore,
3143}
3144
3145#[derive(Debug, Clone, Deserialize)]
3146#[serde(deny_unknown_fields)]
3147pub struct KitScore {
3148    pub kind: String,
3149    pub value: f64,
3150}
3151
3152#[derive(Debug, Clone, Serialize)]
3153pub struct KitSetSimilarityRequest {
3154    pub table: String,
3155    pub column_id: u16,
3156    pub members: Vec<serde_json::Value>,
3157    pub candidate_k: usize,
3158    pub min_jaccard: f32,
3159    pub limit: usize,
3160}
3161
3162#[derive(Debug, Clone, Deserialize)]
3163#[serde(deny_unknown_fields)]
3164pub struct KitSetSimilarityResponse {
3165    pub hits: Vec<KitSetSimilarityHit>,
3166}
3167
3168#[derive(Debug, Clone, Deserialize)]
3169#[serde(deny_unknown_fields)]
3170pub struct KitSetSimilarityHit {
3171    pub row_id: String,
3172    pub estimated_jaccard: f32,
3173    pub exact_jaccard: f32,
3174}
3175
3176#[derive(Debug, Clone, Serialize)]
3177pub struct KitSearchRequest {
3178    pub table: String,
3179    #[serde(skip_serializing_if = "Vec::is_empty")]
3180    pub must: Vec<serde_json::Value>,
3181    pub retrievers: Vec<serde_json::Value>,
3182    pub fusion: serde_json::Value,
3183    #[serde(skip_serializing_if = "Option::is_none")]
3184    pub rerank: Option<serde_json::Value>,
3185    pub limit: usize,
3186    #[serde(skip_serializing_if = "Option::is_none")]
3187    pub projection: Option<Vec<u16>>,
3188    #[serde(skip_serializing_if = "Option::is_none")]
3189    pub deadline_ms: Option<u64>,
3190    #[serde(skip_serializing_if = "Option::is_none")]
3191    pub max_work: Option<usize>,
3192    #[serde(default)]
3193    pub explain: bool,
3194    #[serde(skip_serializing_if = "Option::is_none")]
3195    pub cursor: Option<String>,
3196}
3197
3198#[derive(Debug, Clone, Deserialize)]
3199#[serde(deny_unknown_fields)]
3200pub struct KitSearchResponse {
3201    pub hits: Vec<KitSearchHit>,
3202    #[serde(default)]
3203    pub trace: Option<serde_json::Value>,
3204    #[serde(default)]
3205    pub next_cursor: Option<String>,
3206}
3207
3208#[derive(Debug, Clone, Deserialize)]
3209#[serde(deny_unknown_fields)]
3210pub struct KitSearchHit {
3211    pub row_id: String,
3212    pub cells: Vec<serde_json::Value>,
3213    pub components: Vec<KitComponentScore>,
3214    pub fused_score: f64,
3215    #[serde(default)]
3216    pub exact_rerank_score: Option<f32>,
3217    #[serde(default)]
3218    pub final_score: Option<f64>,
3219    #[serde(default)]
3220    pub final_rank: Option<usize>,
3221}
3222
3223#[derive(Debug, Clone, Deserialize)]
3224#[serde(deny_unknown_fields)]
3225pub struct KitComponentScore {
3226    pub retriever_name: String,
3227    pub rank: usize,
3228    pub raw_score: KitScore,
3229    pub contribution: f64,
3230}
3231
3232#[derive(Debug, Clone, Serialize)]
3233pub struct ProcedureRequest {
3234    pub procedure: mongreldb_core::StoredProcedure,
3235}
3236
3237#[derive(Debug, Clone, Serialize)]
3238pub struct TriggerRequest {
3239    pub trigger: mongreldb_core::StoredTrigger,
3240    #[serde(skip_serializing_if = "Option::is_none")]
3241    pub idempotency_key: Option<String>,
3242}
3243
3244#[derive(Debug, Clone, Deserialize)]
3245#[serde(deny_unknown_fields)]
3246pub struct ProcedureResponse {
3247    #[serde(default)]
3248    pub status: String,
3249    pub procedure: mongreldb_core::StoredProcedure,
3250}
3251
3252#[derive(Debug, Clone, Deserialize)]
3253#[serde(deny_unknown_fields)]
3254pub struct TriggerResponse {
3255    #[serde(default)]
3256    pub status: Option<String>,
3257    pub trigger: mongreldb_core::StoredTrigger,
3258}
3259
3260#[derive(Debug, Clone, Deserialize)]
3261#[serde(deny_unknown_fields)]
3262pub struct ProceduresResponse {
3263    pub procedures: Vec<mongreldb_core::StoredProcedure>,
3264}
3265
3266#[derive(Debug, Clone, Deserialize)]
3267#[serde(deny_unknown_fields)]
3268pub struct TriggersResponse {
3269    pub triggers: Vec<mongreldb_core::StoredTrigger>,
3270}
3271
3272#[derive(Debug, Clone, Serialize)]
3273pub struct ProcedureCallRequest {
3274    #[serde(default)]
3275    pub args: serde_json::Map<String, serde_json::Value>,
3276    #[serde(skip_serializing_if = "Option::is_none")]
3277    pub idempotency_key: Option<String>,
3278}
3279
3280#[derive(Debug, Clone, Deserialize)]
3281#[serde(deny_unknown_fields)]
3282pub struct ProcedureCallResponse {
3283    pub status: String,
3284    pub committed: bool,
3285    #[serde(default)]
3286    pub epoch: Option<u64>,
3287    #[serde(default)]
3288    pub epoch_text: Option<String>,
3289    pub result: serde_json::Value,
3290}
3291
3292fn validate_procedure_call_response(
3293    response: ProcedureCallResponse,
3294) -> ClientResult<ProcedureCallResponse> {
3295    if response.status != "ok"
3296        || response.committed != response.epoch.is_some()
3297        || response.committed != response.epoch_text.is_some()
3298        || exact_kit_epoch(response.epoch_text.as_deref(), response.epoch)
3299            .map_err(ClientError::Decode)?
3300            != response.epoch
3301    {
3302        return Err(ClientError::Decode(
3303            "procedure call response has invalid durable metadata".into(),
3304        ));
3305    }
3306    Ok(response)
3307}
3308
3309fn procedure_definition_matches(
3310    actual: &mongreldb_core::StoredProcedure,
3311    requested: &mongreldb_core::StoredProcedure,
3312) -> bool {
3313    actual.mode == requested.mode
3314        && actual.params == requested.params
3315        && actual.body == requested.body
3316}
3317
3318fn trigger_definition_matches(
3319    actual: &mongreldb_core::StoredTrigger,
3320    requested: &mongreldb_core::StoredTrigger,
3321) -> bool {
3322    actual.target == requested.target
3323        && actual.timing == requested.timing
3324        && actual.event == requested.event
3325        && actual.update_of == requested.update_of
3326        && actual.target_columns == requested.target_columns
3327        && actual.when == requested.when
3328        && actual.program == requested.program
3329        && actual.enabled
3330}
3331
3332// Internal mirror of the server's error envelope.
3333#[derive(Debug, Deserialize)]
3334#[serde(deny_unknown_fields)]
3335struct KitErrorEnvelope {
3336    #[allow(dead_code)]
3337    status: String,
3338    #[serde(default)]
3339    committed: Option<bool>,
3340    #[serde(default)]
3341    epoch: Option<u64>,
3342    #[serde(default)]
3343    epoch_text: Option<String>,
3344    #[serde(default)]
3345    retryable: Option<bool>,
3346    #[serde(default)]
3347    results: Option<Vec<KitOpResult>>,
3348    error: KitErrorBody,
3349}
3350
3351#[derive(Debug, Deserialize)]
3352#[serde(deny_unknown_fields)]
3353struct KitErrorBody {
3354    code: String,
3355    message: String,
3356    #[serde(default)]
3357    op_index: Option<usize>,
3358}
3359
3360fn exact_json_object_fields(
3361    value: &serde_json::Value,
3362    allowed: &[&str],
3363    required: &[&str],
3364    context: &str,
3365) -> Result<(), String> {
3366    let object = value
3367        .as_object()
3368        .ok_or_else(|| format!("{context} is not an object"))?;
3369    if let Some(field) = object
3370        .keys()
3371        .find(|field| !allowed.contains(&field.as_str()))
3372    {
3373        return Err(format!("{context} contains unknown field {field:?}"));
3374    }
3375    if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
3376        return Err(format!("{context} lacks required field {field:?}"));
3377    }
3378    Ok(())
3379}
3380
3381fn is_exact_query_not_found_response(
3382    body: &[u8],
3383    expected_query_id: mongreldb_query::QueryId,
3384) -> bool {
3385    const FIELDS: &[&str] = &[
3386        "query_id",
3387        "status",
3388        "terminal_state",
3389        "committed",
3390        "committed_statements",
3391        "last_commit_epoch",
3392        "last_commit_epoch_text",
3393        "first_commit_statement_index",
3394        "last_commit_statement_index",
3395        "completed_statements",
3396        "statement_index",
3397        "cancel_outcome",
3398        "cancellation_reason",
3399        "retryable",
3400        "server_state",
3401        "outcome",
3402        "error",
3403    ];
3404    const OUTCOME_FIELDS: &[&str] = &[
3405        "committed",
3406        "committed_statements",
3407        "last_commit_epoch",
3408        "last_commit_epoch_text",
3409        "first_commit_statement_index",
3410        "last_commit_statement_index",
3411        "completed_statements",
3412        "statement_index",
3413        "serialization",
3414    ];
3415    const NULL_FIELDS: &[&str] = &[
3416        "terminal_state",
3417        "committed",
3418        "committed_statements",
3419        "last_commit_epoch",
3420        "last_commit_epoch_text",
3421        "first_commit_statement_index",
3422        "last_commit_statement_index",
3423        "completed_statements",
3424        "statement_index",
3425        "cancellation_reason",
3426    ];
3427    let Ok(value) = strict_json_value(body) else {
3428        return false;
3429    };
3430    if exact_json_object_fields(&value, FIELDS, FIELDS, "query-not-found response").is_err() {
3431        return false;
3432    }
3433    let expected_query_id = expected_query_id.to_string();
3434    if value["query_id"].as_str() != Some(expected_query_id.as_str())
3435        || value["status"].as_str() != Some("unknown")
3436        || value["cancel_outcome"].as_str() != Some("not_found")
3437        || value["server_state"].as_str() != Some("not_found")
3438        || value["retryable"].as_bool() != Some(false)
3439        || NULL_FIELDS.iter().any(|field| !value[*field].is_null())
3440    {
3441        return false;
3442    }
3443    let outcome = &value["outcome"];
3444    if exact_json_object_fields(
3445        outcome,
3446        OUTCOME_FIELDS,
3447        OUTCOME_FIELDS,
3448        "query-not-found outcome",
3449    )
3450    .is_err()
3451        || outcome["serialization"].as_str() != Some("unknown")
3452        || OUTCOME_FIELDS
3453            .iter()
3454            .filter(|field| **field != "serialization")
3455            .any(|field| !outcome[*field].is_null())
3456    {
3457        return false;
3458    }
3459    let error = &value["error"];
3460    exact_json_object_fields(
3461        error,
3462        &["code", "message", "query_id", "committed", "retryable"],
3463        &["code", "message", "query_id", "committed", "retryable"],
3464        "query-not-found error",
3465    )
3466    .is_ok()
3467        && error["code"].as_str() == Some("QUERY_NOT_FOUND")
3468        && error["message"].as_str().is_some()
3469        && error["query_id"].as_str() == Some(expected_query_id.as_str())
3470        && error["committed"].is_null()
3471        && error["retryable"].as_bool() == Some(false)
3472}
3473
3474fn validate_kit_txn_error_json(value: &serde_json::Value) -> Result<(), String> {
3475    let error = value
3476        .get("error")
3477        .ok_or_else(|| "Kit transaction error response lacks error".to_owned())?;
3478    exact_json_object_fields(
3479        error,
3480        &["code", "message", "op_index"],
3481        &["code", "message"],
3482        "Kit transaction error",
3483    )?;
3484    let status = value
3485        .get("status")
3486        .and_then(serde_json::Value::as_str)
3487        .ok_or_else(|| "Kit transaction error response lacks status".to_owned())?;
3488    if matches!(status, "committed" | "outcome_unknown")
3489        && error
3490            .as_object()
3491            .is_some_and(|error| error.contains_key("op_index"))
3492    {
3493        return Err("durable Kit transaction error must omit op_index".into());
3494    }
3495    match status {
3496        "aborted" => {
3497            let enriched = value.get("committed").is_some() || value.get("retryable").is_some();
3498            if enriched {
3499                exact_json_object_fields(
3500                    value,
3501                    &["status", "committed", "retryable", "error"],
3502                    &["status", "committed", "retryable", "error"],
3503                    "Kit transaction error response",
3504                )?;
3505            } else {
3506                exact_json_object_fields(
3507                    value,
3508                    &["status", "error"],
3509                    &["status", "error"],
3510                    "Kit transaction error response",
3511                )?;
3512            }
3513        }
3514        "committed" => exact_json_object_fields(
3515            value,
3516            &[
3517                "status",
3518                "committed",
3519                "epoch",
3520                "epoch_text",
3521                "results",
3522                "retryable",
3523                "error",
3524            ],
3525            &[
3526                "status",
3527                "committed",
3528                "epoch",
3529                "epoch_text",
3530                "retryable",
3531                "error",
3532            ],
3533            "Kit transaction error response",
3534        )?,
3535        "outcome_unknown" => exact_json_object_fields(
3536            value,
3537            &[
3538                "status",
3539                "committed",
3540                "epoch",
3541                "epoch_text",
3542                "retryable",
3543                "error",
3544            ],
3545            &[
3546                "status",
3547                "committed",
3548                "epoch",
3549                "epoch_text",
3550                "retryable",
3551                "error",
3552            ],
3553            "Kit transaction error response",
3554        )?,
3555        _ => return Err("Kit transaction error response status is invalid".into()),
3556    }
3557    Ok(())
3558}
3559
3560fn proven_kit_commit_metadata(value: &serde_json::Value) -> Option<(u64, String)> {
3561    if value.get("status")?.as_str()? != "committed"
3562        || !value.get("committed")?.as_bool()?
3563        || value.get("retryable")?.as_bool()?
3564    {
3565        return None;
3566    }
3567    let error = value.get("error")?.as_object()?;
3568    if error.get("code")?.as_str()? != "COMMIT_OUTCOME"
3569        || error.get("message")?.as_str()?.is_empty()
3570        || error.get("op_index").is_some_and(|index| {
3571            !index.is_null()
3572                && index
3573                    .as_u64()
3574                    .and_then(|index| usize::try_from(index).ok())
3575                    .is_none()
3576        })
3577    {
3578        return None;
3579    }
3580    let epoch = value.get("epoch")?.as_u64()?;
3581    let epoch_text = value.get("epoch_text")?.as_str()?.to_owned();
3582    (exact_kit_epoch(Some(&epoch_text), Some(epoch))
3583        .ok()
3584        .flatten()
3585        == Some(epoch))
3586    .then_some((epoch, epoch_text))
3587}
3588
3589fn decode_http_error(status: u16, body: &[u8]) -> ClientError {
3590    let value = match strict_json_value(body) {
3591        Ok(value) => value,
3592        Err(error) => {
3593            if body
3594                .iter()
3595                .copied()
3596                .find(|byte| !byte.is_ascii_whitespace())
3597                .is_some_and(|byte| matches!(byte, b'{' | b'['))
3598            {
3599                return ClientError::Decode(format!("invalid HTTP error response: {error}"));
3600            }
3601            return ClientError::Http {
3602                status,
3603                body: format!("non-JSON error response ({} bytes)", body.len()),
3604            };
3605        }
3606    };
3607    if status == 404
3608        && value
3609            .get("query_id")
3610            .and_then(serde_json::Value::as_str)
3611            .and_then(|query_id| query_id.parse::<mongreldb_query::QueryId>().ok())
3612            .is_some_and(|query_id| is_exact_query_not_found_response(body, query_id))
3613    {
3614        return match serde_json::from_value::<RemoteQueryErrorResponse>(value) {
3615            Ok(response) => ClientError::Query {
3616                status,
3617                code: RemoteQueryErrorCode::QueryNotFound,
3618                message: response.error.message.clone(),
3619                response: Box::new(response),
3620            },
3621            Err(error) => ClientError::Decode(format!("invalid query-not-found response: {error}")),
3622        };
3623    }
3624    let queryless_sql_response = value.get("query_id").is_none()
3625        && value
3626            .get("error")
3627            .and_then(|error| error.get("query_id"))
3628            .is_none()
3629        && value.get("outcome").is_some();
3630    if queryless_sql_response {
3631        return match serde_json::from_value::<RemoteQueryErrorResponse>(value) {
3632            Ok(response) => match validate_queryless_sql_error(response) {
3633                Ok(response) => ClientError::Query {
3634                    status,
3635                    code: response.error.code.clone(),
3636                    message: response.error.message.clone(),
3637                    response: Box::new(response),
3638                },
3639                Err(error) => {
3640                    ClientError::Decode(format!("invalid queryless SQL error response: {error}"))
3641                }
3642            },
3643            Err(error) => {
3644                ClientError::Decode(format!("invalid queryless SQL error response: {error}"))
3645            }
3646        };
3647    }
3648    let query_response = value.get("query_id").is_some()
3649        || value.get("outcome").is_some()
3650        || value.get("server_state").is_some()
3651        || value
3652            .get("error")
3653            .and_then(|error| error.get("query_id"))
3654            .is_some();
3655    if query_response {
3656        return match serde_json::from_value::<RemoteQueryErrorResponse>(value) {
3657            Ok(response) => {
3658                let query_id = response
3659                    .query_id
3660                    .as_deref()
3661                    .or(response.error.query_id.as_deref())
3662                    .and_then(|query_id| query_id.parse::<mongreldb_query::QueryId>().ok());
3663                match query_id
3664                    .ok_or_else(|| "query error response lacks a valid query_id".to_owned())
3665                    .and_then(|query_id| validate_remote_query_error(response, query_id))
3666                {
3667                    Ok(response) => ClientError::Query {
3668                        status,
3669                        code: response.error.code.clone(),
3670                        message: response.error.message.clone(),
3671                        response: Box::new(response),
3672                    },
3673                    Err(error) => {
3674                        ClientError::Decode(format!("invalid query error response: {error}"))
3675                    }
3676                }
3677            }
3678            Err(error) => ClientError::Decode(format!("invalid query error response: {error}")),
3679        };
3680    }
3681    match serde_json::from_value::<KitErrorEnvelope>(value) {
3682        Ok(env) => match validate_kit_error_envelope(&env) {
3683            Ok(epoch) => ClientError::Kit {
3684                code: KitErrorCode::from_str(&env.error.code),
3685                message: env.error.message,
3686                op_index: env.error.op_index,
3687                status,
3688                committed: env.committed,
3689                epoch,
3690                epoch_text: env.epoch_text,
3691                retryable: env.retryable,
3692            },
3693            Err(message) => ClientError::Decode(message),
3694        },
3695        Err(error) => ClientError::Decode(format!("invalid Kit error response: {error}")),
3696    }
3697}
3698
3699fn validate_kit_error_envelope(env: &KitErrorEnvelope) -> Result<Option<u64>, String> {
3700    if env.status.is_empty()
3701        || env.error.code.is_empty()
3702        || env.error.message.is_empty()
3703        || env.retryable == Some(true)
3704            && !matches!(
3705                env.error.code.as_str(),
3706                "IDEMPOTENCY_STORE_FULL" | "IDEMPOTENCY_STORE_UNAVAILABLE"
3707            )
3708    {
3709        return Err("invalid Kit error metadata".into());
3710    }
3711    let epoch = exact_kit_epoch(env.epoch_text.as_deref(), env.epoch)?;
3712    match env.error.code.as_str() {
3713        "COMMIT_OUTCOME" => {
3714            if env.status != "committed"
3715                || env.committed != Some(true)
3716                || epoch.is_none()
3717                || env.epoch_text.is_none()
3718                || env.retryable != Some(false)
3719                || env.error.op_index.is_some()
3720            {
3721                return Err("invalid committed Kit error metadata".into());
3722            }
3723        }
3724        "QUERY_OUTCOME_UNKNOWN" => {
3725            if env.status != "outcome_unknown"
3726                || env.committed.is_some()
3727                || env.results.is_some()
3728                || env.retryable != Some(false)
3729                || env.error.op_index.is_some()
3730            {
3731                return Err("invalid unknown Kit outcome metadata".into());
3732            }
3733        }
3734        _ => {
3735            let metadata_valid = match env.status.as_str() {
3736                "aborted" => matches!(
3737                    (env.committed, env.retryable),
3738                    (None, None) | (Some(false), Some(_))
3739                ),
3740                "error" => env.committed.is_none() && env.retryable.is_none(),
3741                _ => false,
3742            };
3743            if !metadata_valid || epoch.is_some() || env.results.is_some() {
3744                return Err("invalid aborted Kit error metadata".into());
3745            }
3746        }
3747    }
3748    Ok(epoch)
3749}
3750
3751fn decode_kit_txn_http_error(status: u16, body: &[u8], request: &KitTxnRequest) -> ClientError {
3752    let value = match strict_json_value(body) {
3753        Ok(value) => value,
3754        Err(error) => {
3755            return kit_txn_outcome_unknown(format!(
3756                "invalid Kit transaction error response: {error}"
3757            ))
3758        }
3759    };
3760    let proven_commit = proven_kit_commit_metadata(&value);
3761    if let Err(error) = validate_kit_txn_error_json(&value) {
3762        if let Some((epoch, epoch_text)) = proven_commit.as_ref() {
3763            return committed_kit_txn_decode_error(status, *epoch, epoch_text.clone(), error);
3764        }
3765        return kit_txn_outcome_unknown(error);
3766    }
3767    let env = match serde_json::from_value::<KitErrorEnvelope>(value) {
3768        Ok(env) => env,
3769        Err(error) => {
3770            if let Some((epoch, epoch_text)) = proven_commit {
3771                return committed_kit_txn_decode_error(
3772                    status,
3773                    epoch,
3774                    epoch_text,
3775                    format!(
3776                        "transaction committed but its error result could not be decoded: {error}"
3777                    ),
3778                );
3779            }
3780            return kit_txn_outcome_unknown(format!(
3781                "invalid Kit transaction error response: {error}"
3782            ));
3783        }
3784    };
3785    let epoch = match validate_kit_error_envelope(&env) {
3786        Ok(epoch) => epoch,
3787        Err(error) => {
3788            if let Some((epoch, epoch_text)) = proven_commit {
3789                return committed_kit_txn_decode_error(status, epoch, epoch_text, error);
3790            }
3791            return kit_txn_outcome_unknown(error);
3792        }
3793    };
3794    if env
3795        .error
3796        .op_index
3797        .is_some_and(|op_index| op_index >= request.ops.len())
3798    {
3799        return kit_txn_outcome_unknown("Kit transaction error op_index is out of bounds");
3800    }
3801    if env.committed == Some(true) {
3802        if let Some(results) = env.results.as_deref() {
3803            if let Err(error) = validate_kit_results(results, request) {
3804                let (Some(epoch), Some(epoch_text)) = (epoch, env.epoch_text.clone()) else {
3805                    return kit_txn_outcome_unknown(
3806                        "committed Kit transaction response lost validated epoch metadata",
3807                    );
3808                };
3809                return committed_kit_txn_decode_error(
3810                    status,
3811                    epoch,
3812                    epoch_text,
3813                    format!("transaction committed but its result was invalid: {error}"),
3814                );
3815            }
3816        }
3817    }
3818    ClientError::Kit {
3819        code: KitErrorCode::from_str(&env.error.code),
3820        message: env.error.message,
3821        op_index: env.error.op_index,
3822        status,
3823        committed: env.committed,
3824        epoch,
3825        epoch_text: env.epoch_text,
3826        retryable: env.retryable,
3827    }
3828}
3829
3830fn decode_sql_http_error(
3831    status: u16,
3832    body: &[u8],
3833    expected_query_id: mongreldb_query::QueryId,
3834) -> Result<ClientError, String> {
3835    let value =
3836        strict_json_value(body).map_err(|error| format!("invalid SQL error response: {error}"))?;
3837    if status == 404 && is_exact_query_not_found_response(body, expected_query_id) {
3838        let response = serde_json::from_value::<RemoteQueryErrorResponse>(value)
3839            .map_err(|error| format!("invalid query-not-found response: {error}"))?;
3840        return Ok(ClientError::Query {
3841            status,
3842            code: RemoteQueryErrorCode::QueryNotFound,
3843            message: response.error.message.clone(),
3844            response: Box::new(response),
3845        });
3846    }
3847    let proof = sql_error_commit_proof(&value, expected_query_id);
3848    let response = match serde_json::from_value::<RemoteQueryErrorResponse>(value) {
3849        Ok(response) => response,
3850        Err(error) => {
3851            if let Some(proof) = proof.clone() {
3852                return Ok(committed_sql_receipt_decode_error(
3853                    expected_query_id,
3854                    proof,
3855                    format!("SQL committed but its error response was invalid: {error}"),
3856                ));
3857            }
3858            return Err(format!("invalid SQL error response: {error}"));
3859        }
3860    };
3861    let response = match validate_remote_query_error(response, expected_query_id) {
3862        Ok(response) => response,
3863        Err(error) => {
3864            if let Some(proof) = proof {
3865                return Ok(committed_sql_receipt_decode_error(
3866                    expected_query_id,
3867                    proof,
3868                    format!("SQL committed but its error response was invalid: {error}"),
3869                ));
3870            }
3871            return Err(error);
3872        }
3873    };
3874    Ok(ClientError::Query {
3875        status,
3876        code: response.error.code.clone(),
3877        message: response.error.message.clone(),
3878        response: Box::new(response),
3879    })
3880}
3881
3882fn validate_sql_query_id_header(
3883    headers: &reqwest::header::HeaderMap,
3884    expected_query_id: mongreldb_query::QueryId,
3885) -> Result<(), String> {
3886    let value = headers
3887        .get("x-mongreldb-query-id")
3888        .ok_or_else(|| "SQL response is missing x-mongreldb-query-id".to_owned())?
3889        .to_str()
3890        .map_err(|_| "SQL response has a non-UTF-8 x-mongreldb-query-id".to_owned())?;
3891    if value != expected_query_id.to_string() {
3892        return Err("SQL response x-mongreldb-query-id does not match the request".into());
3893    }
3894    Ok(())
3895}
3896
3897fn client_error_proves_commit(error: &ClientError) -> bool {
3898    matches!(
3899        error,
3900        ClientError::Query { response, .. }
3901            if response.committed == Some(true)
3902                && response.committed_statements.is_some_and(|count| count > 0)
3903                && response.last_commit_epoch.is_some()
3904                && response.last_commit_epoch_text.is_some()
3905    )
3906}
3907
3908fn exact_kit_epoch(text: Option<&str>, numeric: Option<u64>) -> Result<Option<u64>, String> {
3909    let Some(text) = text else {
3910        return Ok(numeric);
3911    };
3912    let exact = text
3913        .parse::<u64>()
3914        .map_err(|_| "epoch_text is not an unsigned integer".to_owned())?;
3915    if exact.to_string() != text {
3916        return Err("epoch_text is not canonical".into());
3917    }
3918    if numeric.is_some_and(|numeric| numeric != exact) {
3919        return Err("epoch and epoch_text disagree".into());
3920    }
3921    Ok(Some(exact))
3922}
3923
3924fn exact_required_u64(field: &str, numeric: u64, text: &str) -> ClientResult<u64> {
3925    let exact = text
3926        .parse::<u64>()
3927        .map_err(|_| ClientError::Decode(format!("{field}_text is not an unsigned integer")))?;
3928    if exact.to_string() != text {
3929        return Err(ClientError::Decode(format!(
3930            "{field}_text is not canonical"
3931        )));
3932    }
3933    if exact != numeric {
3934        return Err(ClientError::Decode(format!(
3935            "{field} and {field}_text disagree"
3936        )));
3937    }
3938    Ok(exact)
3939}
3940
3941fn parse_required_u64_text(field: &str, text: &str) -> ClientResult<u64> {
3942    let value = text
3943        .parse::<u64>()
3944        .map_err(|_| ClientError::Decode(format!("{field} is not an unsigned integer")))?;
3945    if value.to_string() != text {
3946        return Err(ClientError::Decode(format!("{field} is not canonical")));
3947    }
3948    Ok(value)
3949}
3950
3951fn decode_required_json<T: serde::de::DeserializeOwned>(
3952    response: reqwest::blocking::Response,
3953    context: &str,
3954) -> ClientResult<T> {
3955    decode_blocking_json(response, MAX_CONTROL_RESPONSE_BYTES, context)
3956}
3957
3958fn write_outcome_unknown(context: &str, error: impl std::fmt::Display) -> ClientError {
3959    kit_txn_outcome_unknown(format!(
3960        "{context} outcome is unknown because its response could not be confirmed: {error}"
3961    ))
3962}
3963
3964fn decode_write_json<T: serde::de::DeserializeOwned>(
3965    response: reqwest::blocking::Response,
3966    context: &str,
3967) -> ClientResult<T> {
3968    decode_required_json(response, context).map_err(|error| write_outcome_unknown(context, error))
3969}
3970
3971fn validate_committed_write(response: &CommittedWriteResponse, context: &str) -> ClientResult<u64> {
3972    if response.status != "committed" {
3973        return Err(write_outcome_unknown(
3974            context,
3975            "success response does not prove a commit",
3976        ));
3977    }
3978    exact_required_u64("epoch", response.epoch, &response.epoch_text)
3979        .map_err(|error| write_outcome_unknown(context, error))
3980}
3981
3982fn validate_trigger_drop_response(
3983    response: &TriggerDropResponse,
3984    expected_name: &str,
3985) -> ClientResult<()> {
3986    validate_committed_write(
3987        &CommittedWriteResponse {
3988            status: response.status.clone(),
3989            epoch: response.epoch,
3990            epoch_text: response.epoch_text.clone(),
3991        },
3992        "drop trigger",
3993    )?;
3994    let mut names = std::collections::HashSet::new();
3995    let mut identities = std::collections::HashSet::new();
3996    if response.dropped_trigger.name != expected_name
3997        || response.resource_tables.iter().any(|binding| {
3998            binding.name.is_empty()
3999                || !names.insert(&binding.name)
4000                || !identities.insert((binding.table_id, binding.schema_id))
4001        })
4002    {
4003        return Err(write_outcome_unknown(
4004            "drop trigger",
4005            "success response has an invalid resource binding",
4006        ));
4007    }
4008    Ok(())
4009}
4010
4011fn capability_unsupported(message: impl Into<String>) -> ClientError {
4012    let message = message.into();
4013    let response = RemoteQueryErrorResponse {
4014        query_id: None,
4015        status: "failed_before_commit".into(),
4016        terminal_state: Some("failed_before_commit".into()),
4017        committed: Some(false),
4018        committed_statements: Some(0),
4019        last_commit_epoch: None,
4020        last_commit_epoch_text: None,
4021        first_commit_statement_index: None,
4022        last_commit_statement_index: None,
4023        completed_statements: Some(0),
4024        statement_index: Some(0),
4025        cancel_outcome: None,
4026        cancellation_reason: None,
4027        retryable: false,
4028        server_state: None,
4029        outcome: RemoteQueryOutcome::default(),
4030        error: RemoteQueryErrorBody {
4031            code: RemoteQueryErrorCode::CapabilityUnsupported,
4032            message: message.clone(),
4033            query_id: None,
4034            committed: Some(false),
4035            retryable: false,
4036        },
4037    };
4038    ClientError::Query {
4039        status: 0,
4040        code: RemoteQueryErrorCode::CapabilityUnsupported,
4041        message,
4042        response: Box::new(response),
4043    }
4044}
4045
4046fn client_serialization_error(
4047    query_id: Option<mongreldb_query::QueryId>,
4048    message: impl Into<String>,
4049) -> ClientError {
4050    let message = message.into();
4051    let query_id = query_id.map(|query_id| query_id.to_string());
4052    let response = RemoteQueryErrorResponse {
4053        query_id: query_id.clone(),
4054        status: "failed_before_commit".into(),
4055        terminal_state: Some("failed_before_commit".into()),
4056        committed: Some(false),
4057        committed_statements: Some(0),
4058        last_commit_epoch: None,
4059        last_commit_epoch_text: None,
4060        first_commit_statement_index: None,
4061        last_commit_statement_index: None,
4062        completed_statements: Some(0),
4063        statement_index: Some(0),
4064        cancel_outcome: None,
4065        cancellation_reason: None,
4066        retryable: false,
4067        server_state: Some("failed".into()),
4068        outcome: RemoteQueryOutcome {
4069            committed: Some(false),
4070            committed_statements: Some(0),
4071            completed_statements: Some(0),
4072            statement_index: Some(0),
4073            serialization: "failed".into(),
4074            ..RemoteQueryOutcome::default()
4075        },
4076        error: RemoteQueryErrorBody {
4077            code: RemoteQueryErrorCode::SerializationFailed,
4078            message: message.clone(),
4079            query_id,
4080            committed: Some(false),
4081            retryable: false,
4082        },
4083    };
4084    ClientError::Query {
4085        status: 0,
4086        code: RemoteQueryErrorCode::SerializationFailed,
4087        message,
4088        response: Box::new(response),
4089    }
4090}
4091
4092struct IdempotentAttemptError {
4093    error: ClientError,
4094    replay: bool,
4095}
4096
4097impl IdempotentAttemptError {
4098    fn final_error(error: ClientError) -> Self {
4099        Self {
4100            error,
4101            replay: false,
4102        }
4103    }
4104}
4105
4106fn fresh_query_id(previous: mongreldb_query::QueryId) -> ClientResult<mongreldb_query::QueryId> {
4107    let query_id = mongreldb_query::QueryId::random()
4108        .map_err(|error| ClientError::Transport(error.to_string()))?;
4109    if query_id == previous {
4110        return Err(ClientError::Transport(
4111            "generated duplicate SQL query ID".into(),
4112        ));
4113    }
4114    Ok(query_id)
4115}
4116
4117fn max_known(left: Option<usize>, right: Option<usize>) -> Option<usize> {
4118    left.into_iter().chain(right).max()
4119}
4120
4121fn recovered_query_error(status: RemoteQueryStatus, message: String) -> ClientError {
4122    let committed = status.durably_committed();
4123    let code = status
4124        .terminal_error
4125        .as_ref()
4126        .map(|error| error.code.clone())
4127        .unwrap_or_else(|| {
4128            if committed == Some(true) {
4129                RemoteQueryErrorCode::CommitOutcome
4130            } else {
4131                RemoteQueryErrorCode::SerializationFailed
4132            }
4133        });
4134    let response = RemoteQueryErrorResponse {
4135        query_id: Some(status.query_id.clone()),
4136        status: status.status.clone(),
4137        terminal_state: status.terminal_state.clone(),
4138        committed,
4139        committed_statements: max_known(
4140            status.committed_statements,
4141            status.outcome.committed_statements,
4142        ),
4143        last_commit_epoch: status
4144            .last_commit_epoch
4145            .or(status.outcome.last_commit_epoch),
4146        last_commit_epoch_text: status
4147            .last_commit_epoch_text
4148            .clone()
4149            .or_else(|| status.outcome.last_commit_epoch_text.clone()),
4150        first_commit_statement_index: status
4151            .first_commit_statement_index
4152            .or(status.outcome.first_commit_statement_index),
4153        last_commit_statement_index: status
4154            .last_commit_statement_index
4155            .or(status.outcome.last_commit_statement_index),
4156        completed_statements: max_known(
4157            status.completed_statements,
4158            status.outcome.completed_statements,
4159        ),
4160        statement_index: max_known(status.statement_index, status.outcome.statement_index),
4161        cancel_outcome: status.cancel_outcome,
4162        cancellation_reason: (!status.cancellation_reason.is_empty())
4163            .then_some(status.cancellation_reason.clone()),
4164        retryable: status.retryable,
4165        server_state: Some(status.server_state_or_state().to_owned()),
4166        outcome: status.outcome,
4167        error: RemoteQueryErrorBody {
4168            code: code.clone(),
4169            message: message.clone(),
4170            query_id: Some(status.query_id),
4171            committed,
4172            retryable: status.retryable,
4173        },
4174    };
4175    ClientError::Query {
4176        status: 0,
4177        code,
4178        message,
4179        response: Box::new(response),
4180    }
4181}
4182
4183fn recovery_status_is_decisive(status: &RemoteQueryStatus) -> bool {
4184    status.durably_committed() == Some(true)
4185        || status.is_terminal()
4186            && (status.durably_committed().is_some() || status.terminal_error.is_some())
4187}
4188
4189impl MongrelClient {
4190    pub fn builder(url: impl AsRef<str>) -> MongrelClientBuilder {
4191        let base_url = sanitized_base_url(url.as_ref());
4192        MongrelClientBuilder {
4193            invalid_base_url: base_url.is_none(),
4194            base_url: base_url.unwrap_or_default(),
4195            authorization: None,
4196            invalid_authorization: false,
4197            connect_timeout: None,
4198            request_timeout: None,
4199            pool_idle_timeout: None,
4200        }
4201    }
4202
4203    pub fn new(url: &str) -> ClientResult<Self> {
4204        Self::builder(url).build()
4205    }
4206
4207    pub fn with_options(url: impl AsRef<str>, options: RemoteOptions) -> ClientResult<Self> {
4208        let mut builder = Self::builder(url);
4209        if let Some(timeout) = options.transport_timeout {
4210            builder = builder.request_timeout(timeout);
4211        }
4212        builder = match options.auth {
4213            Some(RemoteAuth::Bearer(token)) => builder.bearer_token(token.expose_secret()),
4214            Some(RemoteAuth::Basic { username, password }) => {
4215                builder.basic_auth(username, password.expose_secret())
4216            }
4217            None => builder,
4218        };
4219        builder.build()
4220    }
4221
4222    fn url(&self, path: &str) -> String {
4223        format!("{}{path}", self.base_url)
4224    }
4225
4226    fn url_segments(&self, segments: &[&str]) -> ClientResult<String> {
4227        url_with_segments(&self.base_url, segments)
4228    }
4229
4230    /// Send a request and, on non-2xx, decode a typed [`ClientError::Kit`] when
4231    /// the body is a `KitErrorEnvelope`, else a plain [`ClientError::Http`].
4232    fn check(
4233        &self,
4234        resp: reqwest::blocking::Response,
4235    ) -> ClientResult<reqwest::blocking::Response> {
4236        let status = resp.status();
4237        if status.is_success() {
4238            return Ok(resp);
4239        }
4240        let status_u16 = status.as_u16();
4241        let body = bounded_blocking_bytes(resp, MAX_CONTROL_RESPONSE_BYTES).map_err(|error| {
4242            ClientError::Decode(format!("invalid HTTP error response: {error}"))
4243        })?;
4244        Err(decode_http_error(status_u16, &body))
4245    }
4246
4247    fn write_response(
4248        &self,
4249        response: Result<reqwest::blocking::Response, reqwest::Error>,
4250        context: &str,
4251    ) -> ClientResult<reqwest::blocking::Response> {
4252        let response = response.map_err(|error| write_outcome_unknown(context, error))?;
4253        let status = response.status();
4254        if status.is_success() {
4255            return Ok(response);
4256        }
4257        let body = bounded_blocking_bytes(response, MAX_CONTROL_RESPONSE_BYTES)
4258            .map_err(|error| write_outcome_unknown(context, error))?;
4259        let error = decode_http_error(status.as_u16(), &body);
4260        match error {
4261            ClientError::Decode(message) => Err(write_outcome_unknown(context, message)),
4262            error => Err(error),
4263        }
4264    }
4265
4266    fn check_sql_response(
4267        &self,
4268        response: reqwest::blocking::Response,
4269        query_id: mongreldb_query::QueryId,
4270    ) -> ClientResult<reqwest::blocking::Response> {
4271        let status = response.status();
4272        let pre_handler_auth = matches!(
4273            status,
4274            reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
4275        );
4276        if status.is_success() {
4277            return Ok(response);
4278        }
4279        let header_error = (!pre_handler_auth)
4280            .then(|| validate_sql_query_id_header(response.headers(), query_id).err())
4281            .flatten();
4282        let status = status.as_u16();
4283        let body =
4284            bounded_blocking_bytes(response, MAX_CONTROL_RESPONSE_BYTES).map_err(|error| {
4285                if pre_handler_auth {
4286                    ClientError::Http {
4287                        status,
4288                        body: format!("unreadable authentication error response: {error}"),
4289                    }
4290                } else {
4291                    self.recover_after_transport_loss(query_id, error.to_string())
4292                }
4293            })?;
4294        if pre_handler_auth {
4295            return Err(decode_http_error(status, &body));
4296        }
4297        match decode_sql_http_error(status, &body, query_id) {
4298            Ok(error) if client_error_proves_commit(&error) => Err(error),
4299            Ok(error) if header_error.is_none() => Err(error),
4300            Ok(_) => Err(self.recover_after_transport_loss(
4301                query_id,
4302                header_error.unwrap_or_else(|| "SQL response identity is invalid".into()),
4303            )),
4304            Err(error) => Err(self.recover_after_transport_loss(query_id, error)),
4305        }
4306    }
4307
4308    fn check_query_status_response(
4309        &self,
4310        response: reqwest::blocking::Response,
4311        query_id: mongreldb_query::QueryId,
4312    ) -> ClientResult<reqwest::blocking::Response> {
4313        let status = response.status();
4314        if status.is_success() {
4315            return Ok(response);
4316        }
4317        let status = status.as_u16();
4318        let body = bounded_blocking_bytes(response, MAX_CONTROL_RESPONSE_BYTES)
4319            .map_err(ClientError::Decode)?;
4320        if matches!(status, 401 | 403) {
4321            return Err(decode_http_error(status, &body));
4322        }
4323        match decode_sql_http_error(status, &body, query_id) {
4324            Ok(error) => Err(error),
4325            Err(error) => Err(ClientError::Decode(error)),
4326        }
4327    }
4328
4329    pub fn health(&self) -> ClientResult<String> {
4330        let resp = self.client.get(self.url("/health")).send()?;
4331        let bytes = bounded_blocking_bytes(self.check(resp)?, MAX_CONTROL_RESPONSE_BYTES)
4332            .map_err(ClientError::Transport)?;
4333        String::from_utf8(bytes)
4334            .map_err(|_| ClientError::Decode("invalid health response: non-UTF-8 body".into()))
4335    }
4336
4337    pub fn capabilities(&self) -> ClientResult<ServerCapabilities> {
4338        let response = self.client.get(self.url("/capabilities")).send()?;
4339        decode_blocking_json(
4340            self.check(response)?,
4341            MAX_CONTROL_RESPONSE_BYTES,
4342            "capabilities",
4343        )
4344    }
4345
4346    pub fn sql_cancellation_capabilities(&self) -> ClientResult<SqlCancellationCapabilities> {
4347        let capabilities = self.capabilities()?.sql_cancellation;
4348        if capabilities.version < 2
4349            || !capabilities.client_query_ids
4350            || !capabilities.cancel_endpoint
4351            || !capabilities.query_status
4352            || !capabilities.pre_registration_cancel
4353        {
4354            return Err(capability_unsupported(
4355                "server does not support SQL cancellation capability version 2",
4356            ));
4357        }
4358        Ok(capabilities)
4359    }
4360
4361    pub fn sql_idempotency_capabilities(&self) -> ClientResult<SqlIdempotencyCapabilities> {
4362        let capabilities = self
4363            .capabilities()?
4364            .sql_idempotency
4365            .ok_or_else(|| capability_unsupported("server does not advertise SQL idempotency"))?;
4366        if capabilities.version < 1
4367            || !capabilities.durable_pre_execution_intent
4368            || !capabilities.replay_committed_receipt
4369            || !capabilities.indeterminate_never_reexecutes
4370        {
4371            return Err(capability_unsupported(
4372                "server does not support safe SQL idempotency capability version 1",
4373            ));
4374        }
4375        Ok(capabilities)
4376    }
4377
4378    pub fn sql_pagination_capabilities(&self) -> ClientResult<SqlPaginationCapabilities> {
4379        let capabilities = self
4380            .capabilities()?
4381            .sql_pagination
4382            .ok_or_else(|| capability_unsupported("server does not advertise SQL pagination"))?;
4383        if capabilities.version < 1
4384            || capabilities.continuation_endpoint != "/sql/continue"
4385            || !capabilities.retained_snapshot
4386            || !capabilities.projection_required
4387            || !capabilities.byte_and_token_hints
4388        {
4389            return Err(capability_unsupported(
4390                "server does not support SQL pagination capability version 1",
4391            ));
4392        }
4393        Ok(capabilities)
4394    }
4395
4396    pub fn set_history_retention_epochs(&self, epochs: u64) -> ClientResult<HistoryRetention> {
4397        let resp = self
4398            .client
4399            .put(self.url("/history/retention"))
4400            .json(&serde_json::json!({"history_retention_epochs": epochs}))
4401            .send()?;
4402        decode_blocking_json(
4403            self.check(resp)?,
4404            MAX_CONTROL_RESPONSE_BYTES,
4405            "history retention",
4406        )
4407    }
4408
4409    pub fn history_retention_epochs(&self) -> ClientResult<u64> {
4410        Ok(self.history_retention()?.history_retention_epochs)
4411    }
4412
4413    pub fn earliest_retained_epoch(&self) -> ClientResult<u64> {
4414        Ok(self.history_retention()?.earliest_retained_epoch)
4415    }
4416
4417    fn history_retention(&self) -> ClientResult<HistoryRetention> {
4418        let resp = self.client.get(self.url("/history/retention")).send()?;
4419        decode_blocking_json(
4420            self.check(resp)?,
4421            MAX_CONTROL_RESPONSE_BYTES,
4422            "history retention",
4423        )
4424    }
4425
4426    // ── Table management ──
4427
4428    pub fn list_tables(&self) -> ClientResult<Vec<String>> {
4429        let resp = self.client.get(self.url("/tables")).send()?;
4430        decode_blocking_json(self.check(resp)?, MAX_CONTROL_RESPONSE_BYTES, "table list")
4431    }
4432
4433    pub fn create_table(&self, name: &str, columns: Vec<ColumnDefJson>) -> ClientResult<u64> {
4434        let response = self.write_response(
4435            self.client
4436                .post(self.url("/tables"))
4437                .json(&serde_json::json!({ "name": name, "columns": columns }))
4438                .send(),
4439            "create table",
4440        )?;
4441        let response: TableIdResponse = decode_write_json(response, "create table")?;
4442        exact_required_u64("table_id", response.table_id, &response.table_id_text)
4443            .map_err(|error| write_outcome_unknown("create table", error))
4444    }
4445
4446    pub fn drop_table(&self, name: &str) -> ClientResult<()> {
4447        let response = self.write_response(
4448            self.client
4449                .delete(self.url_segments(&["tables", name])?)
4450                .send(),
4451            "drop table",
4452        )?;
4453        let response: CommittedWriteResponse = decode_write_json(response, "drop table")?;
4454        validate_committed_write(&response, "drop table")?;
4455        Ok(())
4456    }
4457
4458    // ── Table-qualified operations ──
4459
4460    pub fn count(&self, table: &str) -> ClientResult<u64> {
4461        let resp = self
4462            .client
4463            .get(self.url_segments(&["tables", table, "count"])?)
4464            .send()?;
4465        let resp = self.check(resp)?;
4466        let cr: CountResp = decode_blocking_json(resp, MAX_CONTROL_RESPONSE_BYTES, "count")?;
4467        Ok(cr.count)
4468    }
4469
4470    pub fn put(&self, table: &str, row: Vec<(u16, mongreldb_core::Value)>) -> ClientResult<u64> {
4471        let mut json_row = Vec::with_capacity(row.len() * 2);
4472        for (id, value) in &row {
4473            json_row.push(serde_json::json!(id));
4474            json_row.push(value_to_json(value)?);
4475        }
4476        let response = self.write_response(
4477            self.client
4478                .post(self.url_segments(&["tables", table, "put"])?)
4479                .json(&serde_json::json!({ "row": json_row }))
4480                .send(),
4481            "put",
4482        )?;
4483        let response: RowIdResponse = decode_write_json(response, "put")?;
4484        parse_required_u64_text("row_id", &response.row_id)
4485            .map_err(|error| write_outcome_unknown("put", error))
4486    }
4487
4488    pub fn commit(&self, table: &str) -> ClientResult<u64> {
4489        let response = self.write_response(
4490            self.client
4491                .post(self.url_segments(&["tables", table, "commit"])?)
4492                .send(),
4493            "commit",
4494        )?;
4495        let response: EpochResponse = decode_write_json(response, "commit")?;
4496        exact_required_u64("epoch", response.epoch, &response.epoch_text)
4497            .map_err(|error| write_outcome_unknown("commit", error))
4498    }
4499
4500    // ── SQL read ──
4501
4502    pub fn sql(&self, sql: &str) -> ClientResult<Vec<RecordBatch>> {
4503        self.sql_with_options(sql, SqlClientOptions::default())
4504    }
4505
4506    pub fn sql_with_options(
4507        &self,
4508        sql: &str,
4509        mut options: SqlClientOptions,
4510    ) -> ClientResult<Vec<RecordBatch>> {
4511        if options.query_id.is_some() || options.timeout.is_some() {
4512            self.sql_cancellation_capabilities()?;
4513        }
4514        let query_id = match options.query_id.take() {
4515            Some(query_id) => query_id,
4516            None => mongreldb_query::QueryId::random()
4517                .map_err(|error| ClientError::Transport(error.to_string()))?,
4518        };
4519        let timeout_ms = options
4520            .timeout
4521            .map(|timeout| timeout.as_millis().min(u128::from(u64::MAX)) as u64);
4522        let response = self
4523            .client
4524            .post(self.url("/sql"))
4525            .json(&SqlReq {
4526                sql: sql.to_string(),
4527                format: Some("arrow"), // Rust client decodes Arrow IPC directly
4528                query_id,
4529                timeout_ms,
4530                max_output_rows: None,
4531                max_output_bytes: None,
4532                idempotency_key: None,
4533                pagination: None,
4534            })
4535            .send();
4536        let response = match response {
4537            Ok(response) => response,
4538            Err(error) => {
4539                return Err(self.recover_after_transport_loss(query_id, error.to_string()));
4540            }
4541        };
4542        let resp = self.check_sql_response(response, query_id)?;
4543        if let Err(error) = validate_sql_query_id_header(resp.headers(), query_id) {
4544            return Err(self.recover_after_transport_loss(query_id, error));
4545        }
4546        let bytes = bounded_blocking_bytes(resp, MAX_SQL_RESPONSE_BYTES)
4547            .map_err(|error| self.recover_after_transport_loss(query_id, error.to_string()))?;
4548        read_arrow_ipc(&bytes)
4549            .map_err(|error| self.recover_after_transport_loss(query_id, error.to_string()))
4550    }
4551
4552    pub fn sql_write_idempotent(
4553        &self,
4554        sql: &str,
4555        idempotency_key: impl Into<String>,
4556    ) -> ClientResult<RemoteSqlReceipt> {
4557        self.sql_write_idempotent_with_options(sql, idempotency_key, SqlClientOptions::default())
4558    }
4559
4560    pub fn sql_write_idempotent_with_options(
4561        &self,
4562        sql: &str,
4563        idempotency_key: impl Into<String>,
4564        mut options: SqlClientOptions,
4565    ) -> ClientResult<RemoteSqlReceipt> {
4566        let idempotency_key = idempotency_key.into();
4567        if idempotency_key.is_empty() || idempotency_key.len() > 256 {
4568            return Err(ClientError::Decode(
4569                "SQL idempotency key must contain 1 to 256 bytes".into(),
4570            ));
4571        }
4572        self.sql_idempotency_capabilities()?;
4573        let query_id = match options.query_id.take() {
4574            Some(query_id) => query_id,
4575            None => mongreldb_query::QueryId::random()
4576                .map_err(|error| ClientError::Transport(error.to_string()))?,
4577        };
4578        let result =
4579            self.sql_write_idempotent_once(sql, &idempotency_key, &options, query_id, None);
4580        if result.as_ref().is_err_and(|error| error.replay) {
4581            self.sql_idempotency_capabilities()?;
4582            return self
4583                .sql_write_idempotent_once(
4584                    sql,
4585                    &idempotency_key,
4586                    &options,
4587                    fresh_query_id(query_id)?,
4588                    Some(query_id),
4589                )
4590                .map_err(|error| error.error);
4591        }
4592        result.map_err(|error| error.error)
4593    }
4594
4595    fn sql_write_idempotent_once(
4596        &self,
4597        sql: &str,
4598        idempotency_key: &str,
4599        options: &SqlClientOptions,
4600        query_id: mongreldb_query::QueryId,
4601        expected_original_query_id: Option<mongreldb_query::QueryId>,
4602    ) -> Result<RemoteSqlReceipt, IdempotentAttemptError> {
4603        let timeout_ms = options
4604            .timeout
4605            .map(|timeout| timeout.as_millis().min(u128::from(u64::MAX)) as u64);
4606        let response = self
4607            .client
4608            .post(self.url("/sql"))
4609            .json(&SqlReq {
4610                sql: sql.to_owned(),
4611                format: None,
4612                query_id,
4613                timeout_ms,
4614                max_output_rows: None,
4615                max_output_bytes: None,
4616                idempotency_key: Some(idempotency_key.to_owned()),
4617                pagination: None,
4618            })
4619            .send();
4620        let response = match response {
4621            Ok(response) => response,
4622            Err(error) => {
4623                return Err(self.idempotent_attempt_loss(query_id, error.to_string()));
4624            }
4625        };
4626        let response = self
4627            .check_sql_response(response, query_id)
4628            .map_err(IdempotentAttemptError::final_error)?;
4629        let header_error = validate_sql_query_id_header(response.headers(), query_id).err();
4630        let bytes = bounded_blocking_bytes(response, MAX_CONTROL_RESPONSE_BYTES)
4631            .map_err(|error| self.idempotent_attempt_loss(query_id, error))?;
4632        match decode_remote_sql_receipt(&bytes, query_id, expected_original_query_id) {
4633            Ok(receipt) if header_error.is_none() => Ok(receipt),
4634            Ok(_) => {
4635                let proof = strict_json_value(&bytes).ok().and_then(|value| {
4636                    sql_receipt_commit_proof(&value, query_id, expected_original_query_id)
4637                });
4638                match proof {
4639                    Some(proof) => Err(IdempotentAttemptError::final_error(
4640                        committed_sql_receipt_decode_error(
4641                            query_id,
4642                            proof,
4643                            header_error
4644                                .clone()
4645                                .unwrap_or_else(|| "SQL response identity is invalid".into()),
4646                        ),
4647                    )),
4648                    None => Err(self.idempotent_attempt_loss(
4649                        query_id,
4650                        header_error.unwrap_or_else(|| "SQL response identity is invalid".into()),
4651                    )),
4652                }
4653            }
4654            Err(SqlReceiptDecodeError::KnownCommit(error)) => {
4655                Err(IdempotentAttemptError::final_error(error))
4656            }
4657            Err(SqlReceiptDecodeError::Unknown(error)) => {
4658                Err(self.idempotent_attempt_loss(query_id, error))
4659            }
4660        }
4661    }
4662
4663    fn idempotent_attempt_loss(
4664        &self,
4665        query_id: mongreldb_query::QueryId,
4666        message: String,
4667    ) -> IdempotentAttemptError {
4668        let missing = self
4669            .client
4670            .get(self.url(&format!("/queries/{query_id}")))
4671            .timeout(SQL_RECOVERY_REQUEST_TIMEOUT)
4672            .send()
4673            .ok()
4674            .filter(|response| response.status() == reqwest::StatusCode::NOT_FOUND)
4675            .and_then(|response| bounded_blocking_bytes(response, MAX_CONTROL_RESPONSE_BYTES).ok())
4676            .is_some_and(|body| is_exact_query_not_found_response(&body, query_id));
4677        if missing {
4678            return IdempotentAttemptError {
4679                error: ClientError::QueryOutcomeUnknown {
4680                    query_id: query_id.to_string(),
4681                    message,
4682                    status: None,
4683                    cancel_outcome: None,
4684                },
4685                replay: true,
4686            };
4687        }
4688        IdempotentAttemptError::final_error(self.recover_after_transport_loss(query_id, message))
4689    }
4690
4691    pub fn sql_page(&self, sql: &str, mut options: SqlPageOptions) -> ClientResult<RemoteSqlPage> {
4692        validate_sql_page_options(&options)?;
4693        self.sql_pagination_capabilities()?;
4694        let query_id = match options.query_id.take() {
4695            Some(query_id) => query_id,
4696            None => mongreldb_query::QueryId::random()
4697                .map_err(|error| ClientError::Transport(error.to_string()))?,
4698        };
4699        let timeout_ms = options
4700            .timeout
4701            .map(|timeout| timeout.as_millis().min(u128::from(u64::MAX)) as u64);
4702        let response = self
4703            .client
4704            .post(self.url("/sql"))
4705            .json(&SqlReq {
4706                sql: sql.to_owned(),
4707                format: None,
4708                query_id,
4709                timeout_ms,
4710                max_output_rows: options.max_output_rows,
4711                max_output_bytes: options.max_output_bytes,
4712                idempotency_key: None,
4713                pagination: Some(SqlPaginationReq {
4714                    page_size_rows: options.page_size_rows,
4715                    projection: options.projection.clone(),
4716                    max_page_bytes: options.max_page_bytes,
4717                    max_page_tokens: options.max_page_tokens,
4718                }),
4719            })
4720            .send();
4721        let response = match response {
4722            Ok(response) => response,
4723            Err(error) => {
4724                return Err(self.recover_after_transport_loss(query_id, error.to_string()));
4725            }
4726        };
4727        let page = bounded_blocking_bytes(
4728            {
4729                let response = self.check_sql_response(response, query_id)?;
4730                if let Err(error) = validate_sql_query_id_header(response.headers(), query_id) {
4731                    return Err(self.recover_after_transport_loss(query_id, error));
4732                }
4733                response
4734            },
4735            MAX_SQL_RESPONSE_BYTES,
4736        )
4737        .and_then(|bytes| {
4738            strict_json::<RemoteSqlPage>(&bytes, "SQL page").map_err(|error| error.to_string())
4739        })
4740        .and_then(|page| validate_remote_sql_page(page, Some(&options)));
4741        page.map_err(|error| self.recover_after_transport_loss(query_id, error))
4742    }
4743
4744    pub fn continue_sql_page(
4745        &self,
4746        cursor: &str,
4747        mut options: RemoteSqlControlOptions,
4748    ) -> ClientResult<RemoteSqlPage> {
4749        if cursor.is_empty() || cursor.len() > 2_048 {
4750            return Err(ClientError::Decode(
4751                "SQL continuation cursor must contain 1 to 2048 bytes".into(),
4752            ));
4753        }
4754        self.sql_pagination_capabilities()?;
4755        let query_id = match options.query_id.take() {
4756            Some(query_id) => query_id,
4757            None => mongreldb_query::QueryId::random()
4758                .map_err(|error| ClientError::Transport(error.to_string()))?,
4759        };
4760        if options.timeout == Some(std::time::Duration::ZERO) {
4761            return Err(ClientError::Decode("timeout must be positive".into()));
4762        }
4763        let timeout_ms = options
4764            .timeout
4765            .map(|timeout| timeout.as_millis().min(u128::from(u64::MAX)) as u64);
4766        let response = match self
4767            .client
4768            .post(self.url("/sql/continue"))
4769            .json(&serde_json::json!({
4770                "cursor": cursor,
4771                "operation_id": query_id,
4772                "timeout_ms": timeout_ms,
4773            }))
4774            .send()
4775        {
4776            Ok(response) => response,
4777            Err(error) => {
4778                return Err(self.recover_after_transport_loss(query_id, error.to_string()))
4779            }
4780        };
4781        let response = self.check_sql_response(response, query_id)?;
4782        validate_sql_query_id_header(response.headers(), query_id)
4783            .map_err(|error| client_serialization_error(Some(query_id), error))?;
4784        bounded_blocking_bytes(response, MAX_SQL_RESPONSE_BYTES)
4785            .and_then(|bytes| {
4786                strict_json::<RemoteSqlPage>(&bytes, "SQL page").map_err(|error| error.to_string())
4787            })
4788            .and_then(|page| validate_remote_sql_page(page, None))
4789            .map_err(|error| client_serialization_error(Some(query_id), error))
4790    }
4791
4792    pub fn start_sql(
4793        &self,
4794        sql: impl Into<String>,
4795        mut options: SqlClientOptions,
4796    ) -> ClientResult<RemoteSqlQueryHandle> {
4797        self.sql_cancellation_capabilities()?;
4798        let query_id = match options.query_id {
4799            Some(query_id) => query_id,
4800            None => mongreldb_query::QueryId::random()
4801                .map_err(|error| ClientError::Transport(error.to_string()))?,
4802        };
4803        options.query_id = Some(query_id);
4804        let client = self.clone();
4805        let cancel_client = self.clone();
4806        let sql = sql.into();
4807        let result = std::thread::Builder::new()
4808            .name(format!("mongreldb-sql-{query_id}"))
4809            .spawn(move || client.sql_with_options(&sql, options))
4810            .map_err(|error| ClientError::Transport(error.to_string()))?;
4811        Ok(RemoteSqlQueryHandle {
4812            query_id,
4813            client: cancel_client,
4814            result,
4815        })
4816    }
4817
4818    pub fn cancel_sql(
4819        &self,
4820        query_id: mongreldb_query::QueryId,
4821    ) -> ClientResult<RemoteCancelOutcome> {
4822        self.sql_cancellation_capabilities()?;
4823        let response = self
4824            .client
4825            .post(self.url(&format!("/queries/{query_id}/cancel")))
4826            .send()?;
4827        let status = response.status();
4828        if !matches!(
4829            status,
4830            reqwest::StatusCode::OK
4831                | reqwest::StatusCode::ACCEPTED
4832                | reqwest::StatusCode::CONFLICT
4833                | reqwest::StatusCode::NOT_FOUND
4834        ) {
4835            return match self.check(response) {
4836                Err(error) => Err(error),
4837                Ok(_) => Err(ClientError::Decode(
4838                    "unexpected successful cancellation response".into(),
4839                )),
4840            };
4841        }
4842        let body: serde_json::Value =
4843            decode_blocking_json(response, MAX_CONTROL_RESPONSE_BYTES, "cancellation")?;
4844        decode_cancel_outcome(&body, query_id, status.as_u16())
4845    }
4846
4847    pub fn query_status(
4848        &self,
4849        query_id: mongreldb_query::QueryId,
4850    ) -> ClientResult<RemoteQueryStatus> {
4851        self.sql_cancellation_capabilities()?;
4852        let response = self
4853            .client
4854            .get(self.url(&format!("/queries/{query_id}")))
4855            .send()?;
4856        let status = decode_blocking_json(
4857            self.check_query_status_response(response, query_id)?,
4858            MAX_CONTROL_RESPONSE_BYTES,
4859            "query status",
4860        )?;
4861        validate_remote_query_status(status, query_id).map_err(ClientError::Decode)
4862    }
4863
4864    fn query_status_optional(
4865        &self,
4866        query_id: mongreldb_query::QueryId,
4867        timeout: std::time::Duration,
4868    ) -> Option<RemoteQueryStatus> {
4869        let response = self
4870            .client
4871            .get(self.url(&format!("/queries/{query_id}")))
4872            .timeout(timeout)
4873            .send()
4874            .ok()?;
4875        if !response.status().is_success() {
4876            return None;
4877        }
4878        let status =
4879            decode_blocking_json(response, MAX_CONTROL_RESPONSE_BYTES, "query status").ok()?;
4880        validate_remote_query_status(status, query_id).ok()
4881    }
4882
4883    fn cancel_sql_for_recovery(
4884        &self,
4885        query_id: mongreldb_query::QueryId,
4886        timeout: std::time::Duration,
4887    ) -> Option<RemoteCancelOutcome> {
4888        let response = self
4889            .client
4890            .post(self.url(&format!("/queries/{query_id}/cancel")))
4891            .timeout(timeout)
4892            .send()
4893            .ok()?;
4894        let status = response.status();
4895        if !matches!(
4896            status,
4897            reqwest::StatusCode::OK
4898                | reqwest::StatusCode::ACCEPTED
4899                | reqwest::StatusCode::CONFLICT
4900                | reqwest::StatusCode::NOT_FOUND
4901        ) {
4902            return None;
4903        }
4904        let body =
4905            decode_blocking_json(response, MAX_CONTROL_RESPONSE_BYTES, "cancellation").ok()?;
4906        decode_cancel_outcome(&body, query_id, status.as_u16()).ok()
4907    }
4908
4909    fn recover_after_transport_loss(
4910        &self,
4911        query_id: mongreldb_query::QueryId,
4912        message: String,
4913    ) -> ClientError {
4914        let deadline = std::time::Instant::now() + SQL_RECOVERY_WINDOW;
4915        let mut status = self.query_status_optional(query_id, SQL_RECOVERY_REQUEST_TIMEOUT);
4916        if let Some(decisive) = status
4917            .as_ref()
4918            .filter(|status| recovery_status_is_decisive(status))
4919        {
4920            return recovered_query_error(decisive.clone(), message);
4921        }
4922        let cancel_outcome = self.cancel_sql_for_recovery(
4923            query_id,
4924            deadline
4925                .saturating_duration_since(std::time::Instant::now())
4926                .min(SQL_RECOVERY_REQUEST_TIMEOUT),
4927        );
4928        if status.is_none() && cancel_outcome == Some(RemoteCancelOutcome::NotFound) {
4929            return ClientError::QueryOutcomeUnknown {
4930                query_id: query_id.to_string(),
4931                message,
4932                status: None,
4933                cancel_outcome,
4934            };
4935        }
4936        while !status.as_ref().is_some_and(recovery_status_is_decisive)
4937            && std::time::Instant::now() < deadline
4938        {
4939            std::thread::sleep(
4940                deadline
4941                    .saturating_duration_since(std::time::Instant::now())
4942                    .min(SQL_RECOVERY_POLL_INTERVAL),
4943            );
4944            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
4945            if remaining.is_zero() {
4946                break;
4947            }
4948            status = self
4949                .query_status_optional(query_id, remaining.min(SQL_RECOVERY_REQUEST_TIMEOUT))
4950                .or(status);
4951        }
4952        if let Some(decisive) = status
4953            .as_ref()
4954            .filter(|status| recovery_status_is_decisive(status))
4955        {
4956            return recovered_query_error(decisive.clone(), message);
4957        }
4958        ClientError::QueryOutcomeUnknown {
4959            query_id: query_id.to_string(),
4960            message,
4961            status: status.map(Box::new),
4962            cancel_outcome,
4963        }
4964    }
4965
4966    // ── Atomic txn (legacy, raw) ──
4967
4968    pub fn txn(&self, ops: Vec<TxnOp>) -> ClientResult<()> {
4969        let response = self.write_response(
4970            self.client
4971                .post(self.url("/txn"))
4972                .json(&serde_json::json!({ "ops": ops }))
4973                .send(),
4974            "transaction",
4975        )?;
4976        let response: CommittedWriteResponse = decode_write_json(response, "transaction")?;
4977        validate_committed_write(&response, "transaction")?;
4978        Ok(())
4979    }
4980
4981    // ── Typed Kit surface ──
4982
4983    /// Fetch one table's schema + constraint metadata (`GET /kit/schema/{t}`).
4984    pub fn kit_schema(&self, table: &str) -> ClientResult<TableSchemaInfo> {
4985        let resp = self
4986            .client
4987            .get(self.url_segments(&["kit", "schema", table])?)
4988            .send()?;
4989        decode_blocking_json(self.check(resp)?, MAX_CONTROL_RESPONSE_BYTES, "Kit schema")
4990    }
4991
4992    /// Run a typed atomic batch (`POST /kit/txn`). Constraint violations and
4993    /// conflicts return [`ClientError::Kit`] with the matching code.
4994    pub fn kit_txn(&self, req: &KitTxnRequest) -> ClientResult<KitTxnResponse> {
4995        let resp = self
4996            .client
4997            .post(self.url("/kit/txn"))
4998            .json(req)
4999            .send()
5000            .map_err(|error| {
5001                kit_txn_outcome_unknown(format!(
5002                    "Kit transaction transport failed before outcome confirmation: {error}"
5003                ))
5004            })?;
5005        if !resp.status().is_success() {
5006            let status = resp.status().as_u16();
5007            if matches!(status, 401 | 403) {
5008                return Err(kit_txn_auth_error(status));
5009            }
5010            let body = bounded_blocking_bytes(resp, MAX_SQL_RESPONSE_BYTES).map_err(|error| {
5011                kit_txn_outcome_unknown(format!(
5012                    "Kit transaction error response could not be read: {error}"
5013                ))
5014            })?;
5015            return Err(decode_kit_txn_http_error(status, &body, req));
5016        }
5017        let status = resp.status().as_u16();
5018        let body = bounded_blocking_bytes(resp, MAX_SQL_RESPONSE_BYTES).map_err(|error| {
5019            kit_txn_outcome_unknown(format!(
5020                "Kit transaction success response could not be read: {error}"
5021            ))
5022        })?;
5023        decode_kit_txn_success(&body, req, status)
5024    }
5025
5026    /// Run a native typed query (`POST /kit/query`) returning physical row ids
5027    /// and typed cells. Conditions intersect in the row-id space; this is the
5028    /// native counterpart to SQL reads (which hide row ids).
5029    pub fn kit_query(&self, req: &KitQueryRequest) -> ClientResult<KitQueryResponse> {
5030        let resp = self.client.post(self.url("/kit/query")).json(req).send()?;
5031        let response =
5032            decode_blocking_json(self.check(resp)?, MAX_SQL_RESPONSE_BYTES, "Kit query")?;
5033        validate_kit_query_response(response, req)
5034    }
5035
5036    pub fn kit_retrieve(&self, req: &KitRetrieveRequest) -> ClientResult<KitRetrieveResponse> {
5037        self.kit_retrieve_with_options(req, &AiExecutionOptions::default())
5038    }
5039
5040    pub fn kit_retrieve_with_options(
5041        &self,
5042        req: &KitRetrieveRequest,
5043        options: &AiExecutionOptions,
5044    ) -> ClientResult<KitRetrieveResponse> {
5045        let resp = self
5046            .client
5047            .post(self.url("/kit/retrieve"))
5048            .json(&WithAiExecutionOptions {
5049                request: req,
5050                options,
5051            })
5052            .send()?;
5053        decode_blocking_json(self.check(resp)?, MAX_SQL_RESPONSE_BYTES, "Kit retrieve")
5054    }
5055
5056    /// Embed `text` under the active ANN semantic identity and retrieve (0.64+).
5057    pub fn kit_retrieve_text(
5058        &self,
5059        req: &KitRetrieveTextRequest,
5060    ) -> ClientResult<KitRetrieveTextResponse> {
5061        self.kit_retrieve_text_with_options(req, &AiExecutionOptions::default())
5062    }
5063
5064    pub fn kit_retrieve_text_with_options(
5065        &self,
5066        req: &KitRetrieveTextRequest,
5067        options: &AiExecutionOptions,
5068    ) -> ClientResult<KitRetrieveTextResponse> {
5069        let resp = self
5070            .client
5071            .post(self.url("/kit/retrieve_text"))
5072            .json(&WithAiExecutionOptions {
5073                request: req,
5074                options,
5075            })
5076            .send()?;
5077        decode_blocking_json(
5078            self.check(resp)?,
5079            MAX_SQL_RESPONSE_BYTES,
5080            "Kit retrieve_text",
5081        )
5082    }
5083
5084    pub fn kit_ann_rerank(&self, req: &KitAnnRerankRequest) -> ClientResult<KitAnnRerankResponse> {
5085        self.kit_ann_rerank_with_options(req, &AiExecutionOptions::default())
5086    }
5087
5088    pub fn kit_ann_rerank_with_options(
5089        &self,
5090        req: &KitAnnRerankRequest,
5091        options: &AiExecutionOptions,
5092    ) -> ClientResult<KitAnnRerankResponse> {
5093        let resp = self
5094            .client
5095            .post(self.url("/kit/ann_rerank"))
5096            .json(&WithAiExecutionOptions {
5097                request: req,
5098                options,
5099            })
5100            .send()?;
5101        decode_blocking_json(self.check(resp)?, MAX_SQL_RESPONSE_BYTES, "Kit ANN rerank")
5102    }
5103
5104    pub fn kit_set_similarity(
5105        &self,
5106        req: &KitSetSimilarityRequest,
5107    ) -> ClientResult<KitSetSimilarityResponse> {
5108        self.kit_set_similarity_with_options(req, &AiExecutionOptions::default())
5109    }
5110
5111    pub fn kit_set_similarity_with_options(
5112        &self,
5113        req: &KitSetSimilarityRequest,
5114        options: &AiExecutionOptions,
5115    ) -> ClientResult<KitSetSimilarityResponse> {
5116        let resp = self
5117            .client
5118            .post(self.url("/kit/set_similarity"))
5119            .json(&WithAiExecutionOptions {
5120                request: req,
5121                options,
5122            })
5123            .send()?;
5124        decode_blocking_json(
5125            self.check(resp)?,
5126            MAX_SQL_RESPONSE_BYTES,
5127            "Kit set similarity",
5128        )
5129    }
5130
5131    pub fn kit_search(&self, req: &KitSearchRequest) -> ClientResult<KitSearchResponse> {
5132        let resp = self.client.post(self.url("/kit/search")).json(req).send()?;
5133        decode_blocking_json(self.check(resp)?, MAX_SQL_RESPONSE_BYTES, "Kit search")
5134    }
5135
5136    pub fn kit_ai_metrics(&self) -> ClientResult<serde_json::Value> {
5137        let resp = self.client.get(self.url("/kit/ai/metrics")).send()?;
5138        decode_blocking_json(
5139            self.check(resp)?,
5140            MAX_CONTROL_RESPONSE_BYTES,
5141            "Kit AI metrics",
5142        )
5143    }
5144
5145    /// Create a table over HTTP (`POST /kit/create_table`) from the complete
5146    /// Kit schema JSON. The body may include constraints, all six public index
5147    /// kinds and their options, partial predicates, and column embedding
5148    /// sources. Returns the assigned table id.
5149    pub fn kit_create_table(&self, body: &serde_json::Value) -> ClientResult<u64> {
5150        let response = self.write_response(
5151            self.client
5152                .post(self.url("/kit/create_table"))
5153                .json(body)
5154                .send(),
5155            "Kit create table",
5156        )?;
5157        let response: TableIdResponse = decode_write_json(response, "Kit create table")?;
5158        exact_required_u64("table_id", response.table_id, &response.table_id_text)
5159            .map_err(|error| write_outcome_unknown("Kit create table", error))
5160    }
5161
5162    pub fn procedures(&self) -> ClientResult<Vec<mongreldb_core::StoredProcedure>> {
5163        let resp = self.client.get(self.url("/procedures")).send()?;
5164        let response: ProceduresResponse = decode_blocking_json(
5165            self.check(resp)?,
5166            MAX_CONTROL_RESPONSE_BYTES,
5167            "procedure list",
5168        )?;
5169        Ok(response.procedures)
5170    }
5171
5172    pub fn procedure(&self, name: &str) -> ClientResult<mongreldb_core::StoredProcedure> {
5173        let resp = self
5174            .client
5175            .get(self.url_segments(&["procedures", name])?)
5176            .send()?;
5177        let response: ProcedureResponse =
5178            decode_blocking_json(self.check(resp)?, MAX_CONTROL_RESPONSE_BYTES, "procedure")?;
5179        Ok(response.procedure)
5180    }
5181
5182    pub fn create_procedure(
5183        &self,
5184        procedure: mongreldb_core::StoredProcedure,
5185    ) -> ClientResult<mongreldb_core::StoredProcedure> {
5186        let expected_name = procedure.name.clone();
5187        let expected_definition = procedure.clone();
5188        let response = self.write_response(
5189            self.client
5190                .post(self.url("/procedures"))
5191                .json(&ProcedureRequest { procedure })
5192                .send(),
5193            "create procedure",
5194        )?;
5195        let response: ProcedureResponse = decode_write_json(response, "create procedure")?;
5196        if response.status != "ok"
5197            || response.procedure.name != expected_name
5198            || response.procedure.version == 0
5199            || response.procedure.created_epoch == 0
5200            || response.procedure.updated_epoch < response.procedure.created_epoch
5201            || response.procedure.checksum.is_empty()
5202            || response.procedure.validate().is_err()
5203            || !procedure_definition_matches(&response.procedure, &expected_definition)
5204        {
5205            return Err(write_outcome_unknown(
5206                "create procedure",
5207                "success response does not match the requested procedure",
5208            ));
5209        }
5210        Ok(response.procedure)
5211    }
5212
5213    pub fn replace_procedure(
5214        &self,
5215        name: &str,
5216        procedure: mongreldb_core::StoredProcedure,
5217    ) -> ClientResult<mongreldb_core::StoredProcedure> {
5218        let expected_definition = procedure.clone();
5219        let response = self.write_response(
5220            self.client
5221                .put(self.url_segments(&["procedures", name])?)
5222                .json(&ProcedureRequest { procedure })
5223                .send(),
5224            "replace procedure",
5225        )?;
5226        let response: ProcedureResponse = decode_write_json(response, "replace procedure")?;
5227        if response.status != "ok"
5228            || response.procedure.name != name
5229            || response.procedure.version == 0
5230            || response.procedure.created_epoch == 0
5231            || response.procedure.updated_epoch < response.procedure.created_epoch
5232            || response.procedure.checksum.is_empty()
5233            || response.procedure.validate().is_err()
5234            || !procedure_definition_matches(&response.procedure, &expected_definition)
5235        {
5236            return Err(write_outcome_unknown(
5237                "replace procedure",
5238                "success response does not match the requested procedure",
5239            ));
5240        }
5241        Ok(response.procedure)
5242    }
5243
5244    pub fn drop_procedure(&self, name: &str) -> ClientResult<()> {
5245        let response = self.write_response(
5246            self.client
5247                .delete(self.url_segments(&["procedures", name])?)
5248                .send(),
5249            "drop procedure",
5250        )?;
5251        let response: CommittedWriteResponse = decode_write_json(response, "drop procedure")?;
5252        validate_committed_write(&response, "drop procedure")?;
5253        Ok(())
5254    }
5255
5256    pub fn call_procedure(
5257        &self,
5258        name: &str,
5259        req: &ProcedureCallRequest,
5260    ) -> ClientResult<ProcedureCallResponse> {
5261        let response = self.write_response(
5262            self.client
5263                .post(self.url_segments(&["procedures", name, "call"])?)
5264                .json(req)
5265                .send(),
5266            "procedure call",
5267        )?;
5268        let response = decode_write_json(response, "procedure call")?;
5269        validate_procedure_call_response(response)
5270            .map_err(|error| write_outcome_unknown("procedure call", error))
5271    }
5272
5273    pub fn kit_call_procedure(
5274        &self,
5275        name: &str,
5276        req: &ProcedureCallRequest,
5277    ) -> ClientResult<ProcedureCallResponse> {
5278        let response = self.write_response(
5279            self.client
5280                .post(self.url_segments(&["kit", "procedures", name, "call"])?)
5281                .json(req)
5282                .send(),
5283            "Kit procedure call",
5284        )?;
5285        let response = decode_write_json(response, "Kit procedure call")?;
5286        validate_procedure_call_response(response)
5287            .map_err(|error| write_outcome_unknown("Kit procedure call", error))
5288    }
5289
5290    pub fn triggers(&self) -> ClientResult<Vec<mongreldb_core::StoredTrigger>> {
5291        let resp = self.client.get(self.url("/triggers")).send()?;
5292        let response: TriggersResponse = decode_blocking_json(
5293            self.check(resp)?,
5294            MAX_CONTROL_RESPONSE_BYTES,
5295            "trigger list",
5296        )?;
5297        Ok(response.triggers)
5298    }
5299
5300    pub fn trigger(&self, name: &str) -> ClientResult<mongreldb_core::StoredTrigger> {
5301        let resp = self
5302            .client
5303            .get(self.url_segments(&["triggers", name])?)
5304            .send()?;
5305        let response: TriggerResponse =
5306            decode_blocking_json(self.check(resp)?, MAX_CONTROL_RESPONSE_BYTES, "trigger")?;
5307        Ok(response.trigger)
5308    }
5309
5310    pub fn create_trigger(
5311        &self,
5312        trigger: mongreldb_core::StoredTrigger,
5313    ) -> ClientResult<mongreldb_core::StoredTrigger> {
5314        self.create_trigger_with_idempotency_key(trigger, None::<String>)
5315    }
5316
5317    pub fn create_trigger_with_idempotency_key(
5318        &self,
5319        trigger: mongreldb_core::StoredTrigger,
5320        idempotency_key: Option<impl Into<String>>,
5321    ) -> ClientResult<mongreldb_core::StoredTrigger> {
5322        let expected_name = trigger.name.clone();
5323        let expected_definition = trigger.clone();
5324        let response = self.write_response(
5325            self.client
5326                .post(self.url("/triggers"))
5327                .json(&TriggerRequest {
5328                    trigger,
5329                    idempotency_key: idempotency_key.map(Into::into),
5330                })
5331                .send(),
5332            "create trigger",
5333        )?;
5334        let response: TriggerResponse = decode_write_json(response, "create trigger")?;
5335        if response.status.as_deref() != Some("ok")
5336            || response.trigger.name != expected_name
5337            || response.trigger.version == 0
5338            || response.trigger.created_epoch == 0
5339            || response.trigger.updated_epoch < response.trigger.created_epoch
5340            || response.trigger.checksum.is_empty()
5341            || response.trigger.validate().is_err()
5342            || !trigger_definition_matches(&response.trigger, &expected_definition)
5343        {
5344            return Err(write_outcome_unknown(
5345                "create trigger",
5346                "success response does not match the requested trigger",
5347            ));
5348        }
5349        Ok(response.trigger)
5350    }
5351
5352    pub fn replace_trigger(
5353        &self,
5354        name: &str,
5355        trigger: mongreldb_core::StoredTrigger,
5356    ) -> ClientResult<mongreldb_core::StoredTrigger> {
5357        self.replace_trigger_with_idempotency_key(name, trigger, None::<String>)
5358    }
5359
5360    pub fn replace_trigger_with_idempotency_key(
5361        &self,
5362        name: &str,
5363        trigger: mongreldb_core::StoredTrigger,
5364        idempotency_key: Option<impl Into<String>>,
5365    ) -> ClientResult<mongreldb_core::StoredTrigger> {
5366        let expected_definition = trigger.clone();
5367        let response = self.write_response(
5368            self.client
5369                .put(self.url_segments(&["triggers", name])?)
5370                .json(&TriggerRequest {
5371                    trigger,
5372                    idempotency_key: idempotency_key.map(Into::into),
5373                })
5374                .send(),
5375            "replace trigger",
5376        )?;
5377        let response: TriggerResponse = decode_write_json(response, "replace trigger")?;
5378        if response.status.as_deref() != Some("ok")
5379            || response.trigger.name != name
5380            || response.trigger.version == 0
5381            || response.trigger.created_epoch == 0
5382            || response.trigger.updated_epoch < response.trigger.created_epoch
5383            || response.trigger.checksum.is_empty()
5384            || response.trigger.validate().is_err()
5385            || !trigger_definition_matches(&response.trigger, &expected_definition)
5386        {
5387            return Err(write_outcome_unknown(
5388                "replace trigger",
5389                "success response does not match the requested trigger",
5390            ));
5391        }
5392        Ok(response.trigger)
5393    }
5394
5395    pub fn drop_trigger(&self, name: &str) -> ClientResult<()> {
5396        self.drop_trigger_with_idempotency_key(name, None::<String>)
5397    }
5398
5399    pub fn drop_trigger_with_idempotency_key(
5400        &self,
5401        name: &str,
5402        idempotency_key: Option<impl Into<String>>,
5403    ) -> ClientResult<()> {
5404        let mut request = self.client.delete(self.url_segments(&["triggers", name])?);
5405        if let Some(idempotency_key) = idempotency_key {
5406            request = request.header("Idempotency-Key", idempotency_key.into());
5407        }
5408        let response = self.write_response(request.send(), "drop trigger")?;
5409        let response: TriggerDropResponse = decode_write_json(response, "drop trigger")?;
5410        validate_trigger_drop_response(&response, name)?;
5411        Ok(())
5412    }
5413}
5414
5415impl AsyncMongrelClient {
5416    pub fn builder(url: impl AsRef<str>) -> AsyncMongrelClientBuilder {
5417        let base_url = sanitized_base_url(url.as_ref());
5418        AsyncMongrelClientBuilder {
5419            invalid_base_url: base_url.is_none(),
5420            base_url: base_url.unwrap_or_default(),
5421            authorization: None,
5422            invalid_authorization: false,
5423            connect_timeout: None,
5424            request_timeout: None,
5425            pool_idle_timeout: None,
5426        }
5427    }
5428
5429    pub fn new(url: &str) -> ClientResult<Self> {
5430        Self::builder(url).build()
5431    }
5432
5433    pub fn with_options(url: impl AsRef<str>, options: RemoteOptions) -> ClientResult<Self> {
5434        let mut builder = Self::builder(url);
5435        if let Some(timeout) = options.transport_timeout {
5436            builder = builder.request_timeout(timeout);
5437        }
5438        builder = match options.auth {
5439            Some(RemoteAuth::Bearer(token)) => builder.bearer_token(token.expose_secret()),
5440            Some(RemoteAuth::Basic { username, password }) => {
5441                builder.basic_auth(username, password.expose_secret())
5442            }
5443            None => builder,
5444        };
5445        builder.build()
5446    }
5447
5448    pub fn try_with_bearer_token(mut self, token: impl AsRef<str>) -> ClientResult<Self> {
5449        self.client = async_http_client(Some(bearer_header(token.as_ref())?), self.transport)?;
5450        Ok(self)
5451    }
5452
5453    pub fn with_bearer_token(self, token: impl AsRef<str>) -> ClientResult<Self> {
5454        self.try_with_bearer_token(token)
5455    }
5456
5457    pub fn try_with_basic_auth(
5458        mut self,
5459        username: impl AsRef<str>,
5460        password: impl AsRef<str>,
5461    ) -> ClientResult<Self> {
5462        self.client = async_http_client(
5463            Some(basic_header(username.as_ref(), password.as_ref())?),
5464            self.transport,
5465        )?;
5466        Ok(self)
5467    }
5468
5469    pub fn with_basic_auth(
5470        self,
5471        username: impl AsRef<str>,
5472        password: impl AsRef<str>,
5473    ) -> ClientResult<Self> {
5474        self.try_with_basic_auth(username, password)
5475    }
5476
5477    fn url(&self, path: &str) -> String {
5478        format!("{}{path}", self.base_url)
5479    }
5480
5481    async fn check(&self, response: reqwest::Response) -> ClientResult<reqwest::Response> {
5482        let status = response.status();
5483        if status.is_success() {
5484            return Ok(response);
5485        }
5486        let status_u16 = status.as_u16();
5487        let body = bounded_async_bytes(response, MAX_CONTROL_RESPONSE_BYTES)
5488            .await
5489            .map_err(|error| {
5490                ClientError::Decode(format!("invalid HTTP error response: {error}"))
5491            })?;
5492        Err(decode_http_error(status_u16, &body))
5493    }
5494
5495    async fn check_sql_response(
5496        &self,
5497        response: reqwest::Response,
5498        query_id: mongreldb_query::QueryId,
5499    ) -> ClientResult<reqwest::Response> {
5500        let status = response.status();
5501        let pre_handler_auth = matches!(
5502            status,
5503            reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
5504        );
5505        if status.is_success() {
5506            return Ok(response);
5507        }
5508        let header_error = (!pre_handler_auth)
5509            .then(|| validate_sql_query_id_header(response.headers(), query_id).err())
5510            .flatten();
5511        let status = status.as_u16();
5512        let body = match bounded_async_bytes(response, MAX_CONTROL_RESPONSE_BYTES).await {
5513            Ok(body) => body,
5514            Err(error) => {
5515                if pre_handler_auth {
5516                    return Err(ClientError::Http {
5517                        status,
5518                        body: format!("unreadable authentication error response: {error}"),
5519                    });
5520                }
5521                return Err(self
5522                    .recover_after_transport_loss(query_id, error.to_string())
5523                    .await);
5524            }
5525        };
5526        if pre_handler_auth {
5527            return Err(decode_http_error(status, &body));
5528        }
5529        match decode_sql_http_error(status, &body, query_id) {
5530            Ok(error) if client_error_proves_commit(&error) => Err(error),
5531            Ok(error) if header_error.is_none() => Err(error),
5532            Ok(_) => Err(self
5533                .recover_after_transport_loss(
5534                    query_id,
5535                    header_error.unwrap_or_else(|| "SQL response identity is invalid".into()),
5536                )
5537                .await),
5538            Err(error) => Err(self.recover_after_transport_loss(query_id, error).await),
5539        }
5540    }
5541
5542    async fn check_query_status_response(
5543        &self,
5544        response: reqwest::Response,
5545        query_id: mongreldb_query::QueryId,
5546    ) -> ClientResult<reqwest::Response> {
5547        let status = response.status();
5548        if status.is_success() {
5549            return Ok(response);
5550        }
5551        let status = status.as_u16();
5552        let body = bounded_async_bytes(response, MAX_CONTROL_RESPONSE_BYTES)
5553            .await
5554            .map_err(ClientError::Decode)?;
5555        if matches!(status, 401 | 403) {
5556            return Err(decode_http_error(status, &body));
5557        }
5558        match decode_sql_http_error(status, &body, query_id) {
5559            Ok(error) => Err(error),
5560            Err(error) => Err(ClientError::Decode(error)),
5561        }
5562    }
5563
5564    pub async fn health(&self) -> ClientResult<String> {
5565        let response = self.client.get(self.url("/health")).send().await?;
5566        let bytes = bounded_async_bytes(self.check(response).await?, MAX_CONTROL_RESPONSE_BYTES)
5567            .await
5568            .map_err(ClientError::Transport)?;
5569        String::from_utf8(bytes)
5570            .map_err(|_| ClientError::Decode("invalid health response: non-UTF-8 body".into()))
5571    }
5572
5573    pub async fn capabilities(&self) -> ClientResult<ServerCapabilities> {
5574        let response = self.client.get(self.url("/capabilities")).send().await?;
5575        decode_async_json(
5576            self.check(response).await?,
5577            MAX_CONTROL_RESPONSE_BYTES,
5578            "capabilities",
5579        )
5580        .await
5581    }
5582
5583    pub async fn sql_cancellation_capabilities(&self) -> ClientResult<SqlCancellationCapabilities> {
5584        let capabilities = self.capabilities().await?.sql_cancellation;
5585        if capabilities.version < 2
5586            || !capabilities.client_query_ids
5587            || !capabilities.cancel_endpoint
5588            || !capabilities.query_status
5589            || !capabilities.pre_registration_cancel
5590        {
5591            return Err(capability_unsupported(
5592                "server does not support SQL cancellation capability version 2",
5593            ));
5594        }
5595        Ok(capabilities)
5596    }
5597
5598    pub async fn sql_idempotency_capabilities(&self) -> ClientResult<SqlIdempotencyCapabilities> {
5599        let capabilities =
5600            self.capabilities().await?.sql_idempotency.ok_or_else(|| {
5601                capability_unsupported("server does not advertise SQL idempotency")
5602            })?;
5603        if capabilities.version < 1
5604            || !capabilities.durable_pre_execution_intent
5605            || !capabilities.replay_committed_receipt
5606            || !capabilities.indeterminate_never_reexecutes
5607        {
5608            return Err(capability_unsupported(
5609                "server does not support safe SQL idempotency capability version 1",
5610            ));
5611        }
5612        Ok(capabilities)
5613    }
5614
5615    pub async fn sql_pagination_capabilities(&self) -> ClientResult<SqlPaginationCapabilities> {
5616        let capabilities =
5617            self.capabilities().await?.sql_pagination.ok_or_else(|| {
5618                capability_unsupported("server does not advertise SQL pagination")
5619            })?;
5620        if capabilities.version < 1
5621            || capabilities.continuation_endpoint != "/sql/continue"
5622            || !capabilities.retained_snapshot
5623            || !capabilities.projection_required
5624            || !capabilities.byte_and_token_hints
5625        {
5626            return Err(capability_unsupported(
5627                "server does not support SQL pagination capability version 1",
5628            ));
5629        }
5630        Ok(capabilities)
5631    }
5632
5633    pub async fn sql(&self, sql: &str) -> ClientResult<Vec<RecordBatch>> {
5634        self.sql_with_options(sql, SqlClientOptions::default())
5635            .await
5636    }
5637
5638    pub async fn sql_with_options(
5639        &self,
5640        sql: &str,
5641        mut options: SqlClientOptions,
5642    ) -> ClientResult<Vec<RecordBatch>> {
5643        if options.query_id.is_some() || options.timeout.is_some() {
5644            self.sql_cancellation_capabilities().await?;
5645        }
5646        let query_id = match options.query_id.take() {
5647            Some(query_id) => query_id,
5648            None => mongreldb_query::QueryId::random()
5649                .map_err(|error| ClientError::Transport(error.to_string()))?,
5650        };
5651        let timeout_ms = options
5652            .timeout
5653            .map(|timeout| timeout.as_millis().min(u128::from(u64::MAX)) as u64);
5654        let response = self
5655            .client
5656            .post(self.url("/sql"))
5657            .json(&SqlReq {
5658                sql: sql.to_string(),
5659                format: Some("arrow"),
5660                query_id,
5661                timeout_ms,
5662                max_output_rows: None,
5663                max_output_bytes: None,
5664                idempotency_key: None,
5665                pagination: None,
5666            })
5667            .send()
5668            .await;
5669        let response = match response {
5670            Ok(response) => response,
5671            Err(error) => {
5672                return Err(self
5673                    .recover_after_transport_loss(query_id, error.to_string())
5674                    .await);
5675            }
5676        };
5677        let response = self.check_sql_response(response, query_id).await?;
5678        if let Err(error) = validate_sql_query_id_header(response.headers(), query_id) {
5679            return Err(self.recover_after_transport_loss(query_id, error).await);
5680        }
5681        let bytes = match bounded_async_bytes(response, MAX_SQL_RESPONSE_BYTES).await {
5682            Ok(bytes) => bytes,
5683            Err(error) => {
5684                return Err(self
5685                    .recover_after_transport_loss(query_id, error.to_string())
5686                    .await)
5687            }
5688        };
5689        match read_arrow_ipc(&bytes) {
5690            Ok(batches) => Ok(batches),
5691            Err(error) => Err(self
5692                .recover_after_transport_loss(query_id, error.to_string())
5693                .await),
5694        }
5695    }
5696
5697    pub async fn sql_write_idempotent(
5698        &self,
5699        sql: &str,
5700        idempotency_key: impl Into<String>,
5701    ) -> ClientResult<RemoteSqlReceipt> {
5702        self.sql_write_idempotent_with_options(sql, idempotency_key, SqlClientOptions::default())
5703            .await
5704    }
5705
5706    pub async fn sql_write_idempotent_with_options(
5707        &self,
5708        sql: &str,
5709        idempotency_key: impl Into<String>,
5710        mut options: SqlClientOptions,
5711    ) -> ClientResult<RemoteSqlReceipt> {
5712        let idempotency_key = idempotency_key.into();
5713        if idempotency_key.is_empty() || idempotency_key.len() > 256 {
5714            return Err(ClientError::Decode(
5715                "SQL idempotency key must contain 1 to 256 bytes".into(),
5716            ));
5717        }
5718        self.sql_idempotency_capabilities().await?;
5719        let query_id = match options.query_id.take() {
5720            Some(query_id) => query_id,
5721            None => mongreldb_query::QueryId::random()
5722                .map_err(|error| ClientError::Transport(error.to_string()))?,
5723        };
5724        let result = self
5725            .sql_write_idempotent_once(sql, &idempotency_key, &options, query_id, None)
5726            .await;
5727        if result.as_ref().is_err_and(|error| error.replay) {
5728            self.sql_idempotency_capabilities().await?;
5729            return self
5730                .sql_write_idempotent_once(
5731                    sql,
5732                    &idempotency_key,
5733                    &options,
5734                    fresh_query_id(query_id)?,
5735                    Some(query_id),
5736                )
5737                .await
5738                .map_err(|error| error.error);
5739        }
5740        result.map_err(|error| error.error)
5741    }
5742
5743    async fn sql_write_idempotent_once(
5744        &self,
5745        sql: &str,
5746        idempotency_key: &str,
5747        options: &SqlClientOptions,
5748        query_id: mongreldb_query::QueryId,
5749        expected_original_query_id: Option<mongreldb_query::QueryId>,
5750    ) -> Result<RemoteSqlReceipt, IdempotentAttemptError> {
5751        let timeout_ms = options
5752            .timeout
5753            .map(|timeout| timeout.as_millis().min(u128::from(u64::MAX)) as u64);
5754        let response = self
5755            .client
5756            .post(self.url("/sql"))
5757            .json(&SqlReq {
5758                sql: sql.to_owned(),
5759                format: None,
5760                query_id,
5761                timeout_ms,
5762                max_output_rows: None,
5763                max_output_bytes: None,
5764                idempotency_key: Some(idempotency_key.to_owned()),
5765                pagination: None,
5766            })
5767            .send()
5768            .await;
5769        let response = match response {
5770            Ok(response) => response,
5771            Err(error) => {
5772                return Err(self
5773                    .idempotent_attempt_loss(query_id, error.to_string())
5774                    .await);
5775            }
5776        };
5777        let response = self
5778            .check_sql_response(response, query_id)
5779            .await
5780            .map_err(IdempotentAttemptError::final_error)?;
5781        let header_error = validate_sql_query_id_header(response.headers(), query_id).err();
5782        let bytes = match bounded_async_bytes(response, MAX_CONTROL_RESPONSE_BYTES).await {
5783            Ok(bytes) => bytes,
5784            Err(error) => return Err(self.idempotent_attempt_loss(query_id, error).await),
5785        };
5786        match decode_remote_sql_receipt(&bytes, query_id, expected_original_query_id) {
5787            Ok(receipt) if header_error.is_none() => Ok(receipt),
5788            Ok(_) => {
5789                let proof = strict_json_value(&bytes).ok().and_then(|value| {
5790                    sql_receipt_commit_proof(&value, query_id, expected_original_query_id)
5791                });
5792                match proof {
5793                    Some(proof) => Err(IdempotentAttemptError::final_error(
5794                        committed_sql_receipt_decode_error(
5795                            query_id,
5796                            proof,
5797                            header_error
5798                                .clone()
5799                                .unwrap_or_else(|| "SQL response identity is invalid".into()),
5800                        ),
5801                    )),
5802                    None => Err(self
5803                        .idempotent_attempt_loss(
5804                            query_id,
5805                            header_error
5806                                .unwrap_or_else(|| "SQL response identity is invalid".into()),
5807                        )
5808                        .await),
5809                }
5810            }
5811            Err(SqlReceiptDecodeError::KnownCommit(error)) => {
5812                Err(IdempotentAttemptError::final_error(error))
5813            }
5814            Err(SqlReceiptDecodeError::Unknown(error)) => {
5815                Err(self.idempotent_attempt_loss(query_id, error).await)
5816            }
5817        }
5818    }
5819
5820    async fn idempotent_attempt_loss(
5821        &self,
5822        query_id: mongreldb_query::QueryId,
5823        message: String,
5824    ) -> IdempotentAttemptError {
5825        let missing = match self
5826            .client
5827            .get(self.url(&format!("/queries/{query_id}")))
5828            .timeout(SQL_RECOVERY_REQUEST_TIMEOUT)
5829            .send()
5830            .await
5831        {
5832            Ok(response) if response.status() == reqwest::StatusCode::NOT_FOUND => {
5833                bounded_async_bytes(response, MAX_CONTROL_RESPONSE_BYTES)
5834                    .await
5835                    .ok()
5836                    .is_some_and(|body| is_exact_query_not_found_response(&body, query_id))
5837            }
5838            _ => false,
5839        };
5840        if missing {
5841            return IdempotentAttemptError {
5842                error: ClientError::QueryOutcomeUnknown {
5843                    query_id: query_id.to_string(),
5844                    message,
5845                    status: None,
5846                    cancel_outcome: None,
5847                },
5848                replay: true,
5849            };
5850        }
5851        IdempotentAttemptError::final_error(
5852            self.recover_after_transport_loss(query_id, message).await,
5853        )
5854    }
5855
5856    pub async fn sql_page(
5857        &self,
5858        sql: &str,
5859        mut options: SqlPageOptions,
5860    ) -> ClientResult<RemoteSqlPage> {
5861        validate_sql_page_options(&options)?;
5862        self.sql_pagination_capabilities().await?;
5863        let query_id = match options.query_id.take() {
5864            Some(query_id) => query_id,
5865            None => mongreldb_query::QueryId::random()
5866                .map_err(|error| ClientError::Transport(error.to_string()))?,
5867        };
5868        let timeout_ms = options
5869            .timeout
5870            .map(|timeout| timeout.as_millis().min(u128::from(u64::MAX)) as u64);
5871        let response = self
5872            .client
5873            .post(self.url("/sql"))
5874            .json(&SqlReq {
5875                sql: sql.to_owned(),
5876                format: None,
5877                query_id,
5878                timeout_ms,
5879                max_output_rows: options.max_output_rows,
5880                max_output_bytes: options.max_output_bytes,
5881                idempotency_key: None,
5882                pagination: Some(SqlPaginationReq {
5883                    page_size_rows: options.page_size_rows,
5884                    projection: options.projection.clone(),
5885                    max_page_bytes: options.max_page_bytes,
5886                    max_page_tokens: options.max_page_tokens,
5887                }),
5888            })
5889            .send()
5890            .await;
5891        let response = match response {
5892            Ok(response) => response,
5893            Err(error) => {
5894                return Err(self
5895                    .recover_after_transport_loss(query_id, error.to_string())
5896                    .await);
5897            }
5898        };
5899        let response = self.check_sql_response(response, query_id).await?;
5900        if let Err(error) = validate_sql_query_id_header(response.headers(), query_id) {
5901            return Err(self.recover_after_transport_loss(query_id, error).await);
5902        }
5903        let page = bounded_async_bytes(response, MAX_SQL_RESPONSE_BYTES)
5904            .await
5905            .and_then(|bytes| {
5906                strict_json::<RemoteSqlPage>(&bytes, "SQL page").map_err(|error| error.to_string())
5907            })
5908            .and_then(|page| validate_remote_sql_page(page, Some(&options)));
5909        match page {
5910            Ok(page) => Ok(page),
5911            Err(error) => Err(self.recover_after_transport_loss(query_id, error).await),
5912        }
5913    }
5914
5915    pub async fn continue_sql_page(
5916        &self,
5917        cursor: &str,
5918        mut options: RemoteSqlControlOptions,
5919    ) -> ClientResult<RemoteSqlPage> {
5920        if cursor.is_empty() || cursor.len() > 2_048 {
5921            return Err(ClientError::Decode(
5922                "SQL continuation cursor must contain 1 to 2048 bytes".into(),
5923            ));
5924        }
5925        self.sql_pagination_capabilities().await?;
5926        let query_id = match options.query_id.take() {
5927            Some(query_id) => query_id,
5928            None => mongreldb_query::QueryId::random()
5929                .map_err(|error| ClientError::Transport(error.to_string()))?,
5930        };
5931        if options.timeout == Some(std::time::Duration::ZERO) {
5932            return Err(ClientError::Decode("timeout must be positive".into()));
5933        }
5934        let timeout_ms = options
5935            .timeout
5936            .map(|timeout| timeout.as_millis().min(u128::from(u64::MAX)) as u64);
5937        let response = self
5938            .client
5939            .post(self.url("/sql/continue"))
5940            .json(&serde_json::json!({
5941                "cursor": cursor,
5942                "operation_id": query_id,
5943                "timeout_ms": timeout_ms,
5944            }))
5945            .send()
5946            .await
5947            .map_err(|error| ClientError::Transport(error.to_string()))?;
5948        let response = self.check_sql_response(response, query_id).await?;
5949        validate_sql_query_id_header(response.headers(), query_id)
5950            .map_err(|error| client_serialization_error(Some(query_id), error))?;
5951        bounded_async_bytes(response, MAX_SQL_RESPONSE_BYTES)
5952            .await
5953            .and_then(|bytes| {
5954                strict_json::<RemoteSqlPage>(&bytes, "SQL page").map_err(|error| error.to_string())
5955            })
5956            .and_then(|page| validate_remote_sql_page(page, None))
5957            .map_err(|error| client_serialization_error(Some(query_id), error))
5958    }
5959
5960    pub async fn cancel_sql(
5961        &self,
5962        query_id: mongreldb_query::QueryId,
5963    ) -> ClientResult<RemoteCancelOutcome> {
5964        self.sql_cancellation_capabilities().await?;
5965        let response = self
5966            .client
5967            .post(self.url(&format!("/queries/{query_id}/cancel")))
5968            .send()
5969            .await?;
5970        let status = response.status();
5971        if !matches!(
5972            status,
5973            reqwest::StatusCode::OK
5974                | reqwest::StatusCode::ACCEPTED
5975                | reqwest::StatusCode::CONFLICT
5976                | reqwest::StatusCode::NOT_FOUND
5977        ) {
5978            return match self.check(response).await {
5979                Err(error) => Err(error),
5980                Ok(_) => Err(ClientError::Decode(
5981                    "unexpected successful cancellation response".into(),
5982                )),
5983            };
5984        }
5985        let body: serde_json::Value =
5986            decode_async_json(response, MAX_CONTROL_RESPONSE_BYTES, "cancellation").await?;
5987        decode_cancel_outcome(&body, query_id, status.as_u16())
5988    }
5989
5990    pub async fn query_status(
5991        &self,
5992        query_id: mongreldb_query::QueryId,
5993    ) -> ClientResult<RemoteQueryStatus> {
5994        self.sql_cancellation_capabilities().await?;
5995        let response = self
5996            .client
5997            .get(self.url(&format!("/queries/{query_id}")))
5998            .send()
5999            .await?;
6000        let status = decode_async_json(
6001            self.check_query_status_response(response, query_id).await?,
6002            MAX_CONTROL_RESPONSE_BYTES,
6003            "query status",
6004        )
6005        .await?;
6006        validate_remote_query_status(status, query_id).map_err(ClientError::Decode)
6007    }
6008
6009    async fn query_status_optional(
6010        &self,
6011        query_id: mongreldb_query::QueryId,
6012        timeout: std::time::Duration,
6013    ) -> Option<RemoteQueryStatus> {
6014        let response = self
6015            .client
6016            .get(self.url(&format!("/queries/{query_id}")))
6017            .timeout(timeout)
6018            .send()
6019            .await
6020            .ok()?;
6021        if !response.status().is_success() {
6022            return None;
6023        }
6024        let status = decode_async_json(response, MAX_CONTROL_RESPONSE_BYTES, "query status")
6025            .await
6026            .ok()?;
6027        validate_remote_query_status(status, query_id).ok()
6028    }
6029
6030    async fn cancel_sql_for_recovery(
6031        &self,
6032        query_id: mongreldb_query::QueryId,
6033        timeout: std::time::Duration,
6034    ) -> Option<RemoteCancelOutcome> {
6035        let response = self
6036            .client
6037            .post(self.url(&format!("/queries/{query_id}/cancel")))
6038            .timeout(timeout)
6039            .send()
6040            .await
6041            .ok()?;
6042        let status = response.status();
6043        if !matches!(
6044            status,
6045            reqwest::StatusCode::OK
6046                | reqwest::StatusCode::ACCEPTED
6047                | reqwest::StatusCode::CONFLICT
6048                | reqwest::StatusCode::NOT_FOUND
6049        ) {
6050            return None;
6051        }
6052        let body = decode_async_json(response, MAX_CONTROL_RESPONSE_BYTES, "cancellation")
6053            .await
6054            .ok()?;
6055        decode_cancel_outcome(&body, query_id, status.as_u16()).ok()
6056    }
6057
6058    async fn recover_after_transport_loss(
6059        &self,
6060        query_id: mongreldb_query::QueryId,
6061        message: String,
6062    ) -> ClientError {
6063        let deadline = tokio::time::Instant::now() + SQL_RECOVERY_WINDOW;
6064        let mut status = self
6065            .query_status_optional(query_id, SQL_RECOVERY_REQUEST_TIMEOUT)
6066            .await;
6067        if let Some(decisive) = status
6068            .as_ref()
6069            .filter(|status| recovery_status_is_decisive(status))
6070        {
6071            return recovered_query_error(decisive.clone(), message);
6072        }
6073        let cancel_outcome = self
6074            .cancel_sql_for_recovery(
6075                query_id,
6076                deadline
6077                    .saturating_duration_since(tokio::time::Instant::now())
6078                    .min(SQL_RECOVERY_REQUEST_TIMEOUT),
6079            )
6080            .await;
6081        if status.is_none() && cancel_outcome == Some(RemoteCancelOutcome::NotFound) {
6082            return ClientError::QueryOutcomeUnknown {
6083                query_id: query_id.to_string(),
6084                message,
6085                status: None,
6086                cancel_outcome,
6087            };
6088        }
6089        while !status.as_ref().is_some_and(recovery_status_is_decisive)
6090            && tokio::time::Instant::now() < deadline
6091        {
6092            tokio::time::sleep(
6093                deadline
6094                    .saturating_duration_since(tokio::time::Instant::now())
6095                    .min(SQL_RECOVERY_POLL_INTERVAL),
6096            )
6097            .await;
6098            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
6099            if remaining.is_zero() {
6100                break;
6101            }
6102            status = self
6103                .query_status_optional(query_id, remaining.min(SQL_RECOVERY_REQUEST_TIMEOUT))
6104                .await
6105                .or(status);
6106        }
6107        if let Some(decisive) = status
6108            .as_ref()
6109            .filter(|status| recovery_status_is_decisive(status))
6110        {
6111            return recovered_query_error(decisive.clone(), message);
6112        }
6113        ClientError::QueryOutcomeUnknown {
6114            query_id: query_id.to_string(),
6115            message,
6116            status: status.map(Box::new),
6117            cancel_outcome,
6118        }
6119    }
6120
6121    /// Fetch one table's schema + constraint metadata (`GET /kit/schema/{t}`).
6122    pub async fn kit_schema(&self, table: &str) -> ClientResult<TableSchemaInfo> {
6123        let response = self
6124            .client
6125            .get(url_with_segments(
6126                &self.base_url,
6127                &["kit", "schema", table],
6128            )?)
6129            .send()
6130            .await?;
6131        decode_async_json(
6132            self.check(response).await?,
6133            MAX_CONTROL_RESPONSE_BYTES,
6134            "Kit schema",
6135        )
6136        .await
6137    }
6138
6139    /// Run a typed atomic batch (`POST /kit/txn`).
6140    pub async fn kit_txn(&self, req: &KitTxnRequest) -> ClientResult<KitTxnResponse> {
6141        let response = self
6142            .client
6143            .post(self.url("/kit/txn"))
6144            .json(req)
6145            .send()
6146            .await
6147            .map_err(|error| {
6148                kit_txn_outcome_unknown(format!(
6149                    "Kit transaction transport failed before outcome confirmation: {error}"
6150                ))
6151            })?;
6152        if !response.status().is_success() {
6153            let status = response.status().as_u16();
6154            if matches!(status, 401 | 403) {
6155                return Err(kit_txn_auth_error(status));
6156            }
6157            let body = bounded_async_bytes(response, MAX_SQL_RESPONSE_BYTES)
6158                .await
6159                .map_err(|error| {
6160                    kit_txn_outcome_unknown(format!(
6161                        "Kit transaction error response could not be read: {error}"
6162                    ))
6163                })?;
6164            return Err(decode_kit_txn_http_error(status, &body, req));
6165        }
6166        let status = response.status().as_u16();
6167        let body = bounded_async_bytes(response, MAX_SQL_RESPONSE_BYTES)
6168            .await
6169            .map_err(|error| {
6170                kit_txn_outcome_unknown(format!(
6171                    "Kit transaction success response could not be read: {error}"
6172                ))
6173            })?;
6174        decode_kit_txn_success(&body, req, status)
6175    }
6176
6177    /// Run a native typed query (`POST /kit/query`).
6178    pub async fn kit_query(&self, req: &KitQueryRequest) -> ClientResult<KitQueryResponse> {
6179        let response = self
6180            .client
6181            .post(self.url("/kit/query"))
6182            .json(req)
6183            .send()
6184            .await?;
6185        let response = decode_async_json(
6186            self.check(response).await?,
6187            MAX_SQL_RESPONSE_BYTES,
6188            "Kit query",
6189        )
6190        .await?;
6191        validate_kit_query_response(response, req)
6192    }
6193
6194    pub async fn kit_retrieve(
6195        &self,
6196        req: &KitRetrieveRequest,
6197    ) -> ClientResult<KitRetrieveResponse> {
6198        self.kit_retrieve_with_options(req, &AiExecutionOptions::default())
6199            .await
6200    }
6201
6202    pub async fn kit_retrieve_with_options(
6203        &self,
6204        req: &KitRetrieveRequest,
6205        options: &AiExecutionOptions,
6206    ) -> ClientResult<KitRetrieveResponse> {
6207        let response = self
6208            .client
6209            .post(self.url("/kit/retrieve"))
6210            .json(&WithAiExecutionOptions {
6211                request: req,
6212                options,
6213            })
6214            .send()
6215            .await?;
6216        decode_async_json(
6217            self.check(response).await?,
6218            MAX_SQL_RESPONSE_BYTES,
6219            "Kit retrieve",
6220        )
6221        .await
6222    }
6223
6224    pub async fn kit_retrieve_text(
6225        &self,
6226        req: &KitRetrieveTextRequest,
6227    ) -> ClientResult<KitRetrieveTextResponse> {
6228        self.kit_retrieve_text_with_options(req, &AiExecutionOptions::default())
6229            .await
6230    }
6231
6232    pub async fn kit_retrieve_text_with_options(
6233        &self,
6234        req: &KitRetrieveTextRequest,
6235        options: &AiExecutionOptions,
6236    ) -> ClientResult<KitRetrieveTextResponse> {
6237        let response = self
6238            .client
6239            .post(self.url("/kit/retrieve_text"))
6240            .json(&WithAiExecutionOptions {
6241                request: req,
6242                options,
6243            })
6244            .send()
6245            .await?;
6246        decode_async_json(
6247            self.check(response).await?,
6248            MAX_SQL_RESPONSE_BYTES,
6249            "Kit retrieve_text",
6250        )
6251        .await
6252    }
6253
6254    pub async fn kit_ann_rerank(
6255        &self,
6256        req: &KitAnnRerankRequest,
6257    ) -> ClientResult<KitAnnRerankResponse> {
6258        self.kit_ann_rerank_with_options(req, &AiExecutionOptions::default())
6259            .await
6260    }
6261
6262    pub async fn kit_ann_rerank_with_options(
6263        &self,
6264        req: &KitAnnRerankRequest,
6265        options: &AiExecutionOptions,
6266    ) -> ClientResult<KitAnnRerankResponse> {
6267        let response = self
6268            .client
6269            .post(self.url("/kit/ann_rerank"))
6270            .json(&WithAiExecutionOptions {
6271                request: req,
6272                options,
6273            })
6274            .send()
6275            .await?;
6276        decode_async_json(
6277            self.check(response).await?,
6278            MAX_SQL_RESPONSE_BYTES,
6279            "Kit ANN rerank",
6280        )
6281        .await
6282    }
6283
6284    pub async fn kit_set_similarity(
6285        &self,
6286        req: &KitSetSimilarityRequest,
6287    ) -> ClientResult<KitSetSimilarityResponse> {
6288        self.kit_set_similarity_with_options(req, &AiExecutionOptions::default())
6289            .await
6290    }
6291
6292    pub async fn kit_set_similarity_with_options(
6293        &self,
6294        req: &KitSetSimilarityRequest,
6295        options: &AiExecutionOptions,
6296    ) -> ClientResult<KitSetSimilarityResponse> {
6297        let response = self
6298            .client
6299            .post(self.url("/kit/set_similarity"))
6300            .json(&WithAiExecutionOptions {
6301                request: req,
6302                options,
6303            })
6304            .send()
6305            .await?;
6306        decode_async_json(
6307            self.check(response).await?,
6308            MAX_SQL_RESPONSE_BYTES,
6309            "Kit set similarity",
6310        )
6311        .await
6312    }
6313
6314    pub async fn kit_search(&self, req: &KitSearchRequest) -> ClientResult<KitSearchResponse> {
6315        let response = self
6316            .client
6317            .post(self.url("/kit/search"))
6318            .json(req)
6319            .send()
6320            .await?;
6321        decode_async_json(
6322            self.check(response).await?,
6323            MAX_SQL_RESPONSE_BYTES,
6324            "Kit search",
6325        )
6326        .await
6327    }
6328
6329    pub async fn kit_ai_metrics(&self) -> ClientResult<serde_json::Value> {
6330        let response = self.client.get(self.url("/kit/ai/metrics")).send().await?;
6331        decode_async_json(
6332            self.check(response).await?,
6333            MAX_CONTROL_RESPONSE_BYTES,
6334            "Kit AI metrics",
6335        )
6336        .await
6337    }
6338}
6339
6340#[derive(Serialize, Clone)]
6341pub struct ColumnDefJson {
6342    pub id: u16,
6343    pub name: String,
6344    pub ty: String,
6345    pub primary_key: bool,
6346}
6347
6348#[derive(Serialize, Clone)]
6349pub struct TxnOp {
6350    pub table: String,
6351    pub op: String,
6352    #[serde(skip_serializing_if = "Option::is_none")]
6353    pub cells: Option<Vec<serde_json::Value>>,
6354    #[serde(skip_serializing_if = "Option::is_none")]
6355    pub row_id: Option<u64>,
6356}
6357
6358fn value_to_json(value: &mongreldb_core::Value) -> ClientResult<serde_json::Value> {
6359    use mongreldb_core::Value;
6360
6361    Ok(match value {
6362        Value::Null => serde_json::Value::Null,
6363        Value::Bool(value) => serde_json::Value::Bool(*value),
6364        Value::Int64(value) => serde_json::Value::Number((*value).into()),
6365        Value::Float64(value) => serde_json::Number::from_f64(*value)
6366            .map(serde_json::Value::Number)
6367            .ok_or_else(|| ClientError::Decode("legacy put rejects non-finite floats".into()))?,
6368        Value::Bytes(value) => tagged_hex_value("bytes", value),
6369        Value::Embedding(values) => serde_json::Value::Array(
6370            values
6371                .iter()
6372                .map(|value| {
6373                    serde_json::Number::from_f64(f64::from(*value))
6374                        .map(serde_json::Value::Number)
6375                        .ok_or_else(|| {
6376                            ClientError::Decode(
6377                                "legacy put rejects non-finite embedding values".into(),
6378                            )
6379                        })
6380                })
6381                .collect::<ClientResult<Vec<_>>>()?,
6382        ),
6383        Value::GeneratedEmbedding(value) => serde_json::Value::Array(
6384            value
6385                .vector
6386                .iter()
6387                .map(|value| {
6388                    serde_json::Number::from_f64(f64::from(*value))
6389                        .map(serde_json::Value::Number)
6390                        .ok_or_else(|| {
6391                            ClientError::Decode(
6392                                "legacy put rejects non-finite embedding values".into(),
6393                            )
6394                        })
6395                })
6396                .collect::<ClientResult<Vec<_>>>()?,
6397        ),
6398        Value::Decimal(value) => serde_json::json!({
6399            "$mongreldb_type": "decimal",
6400            "unscaled": value.to_string(),
6401        }),
6402        Value::Interval {
6403            months,
6404            days,
6405            nanos,
6406        } => serde_json::json!({
6407            "$mongreldb_type": "interval",
6408            "months": months.to_string(),
6409            "days": days.to_string(),
6410            "nanos": nanos.to_string(),
6411        }),
6412        Value::Uuid(value) => tagged_hex_value("uuid", value),
6413        Value::Json(value) => {
6414            std::str::from_utf8(value)
6415                .map_err(|_| ClientError::Decode("legacy put JSON is not UTF-8".into()))?;
6416            serde_json::from_slice::<serde_json::Value>(value).map_err(|error| {
6417                ClientError::Decode(format!("legacy put JSON is invalid: {error}"))
6418            })?;
6419            tagged_hex_value("json", value)
6420        }
6421    })
6422}
6423
6424fn tagged_hex_value(kind: &str, bytes: &[u8]) -> serde_json::Value {
6425    serde_json::json!({
6426        "$mongreldb_type": kind,
6427        "hex": hex_bytes(bytes),
6428    })
6429}
6430
6431fn hex_bytes(bytes: &[u8]) -> String {
6432    const HEX: &[u8; 16] = b"0123456789abcdef";
6433    let mut encoded = String::with_capacity(bytes.len() * 2);
6434    for byte in bytes {
6435        encoded.push(HEX[(byte >> 4) as usize] as char);
6436        encoded.push(HEX[(byte & 0x0f) as usize] as char);
6437    }
6438    encoded
6439}
6440
6441fn read_arrow_ipc(bytes: &[u8]) -> ClientResult<Vec<RecordBatch>> {
6442    if bytes.is_empty() {
6443        return Ok(Vec::new());
6444    }
6445    let cursor = Cursor::new(bytes);
6446    let reader = FileReader::try_new(cursor, None)
6447        .map_err(|e| ClientError::Decode(format!("arrow ipc: {e}")))?;
6448    reader
6449        .into_iter()
6450        .collect::<std::result::Result<Vec<_>, _>>()
6451        .map_err(|e| ClientError::Decode(format!("arrow read: {e}")))
6452}
6453
6454/// A replication follower that polls a leader's `/wal/stream` endpoint and
6455/// applies WAL records to a local database directory via `SharedWal::replay`.
6456///
6457/// Usage:
6458/// ```no_run
6459/// use mongreldb_client::ReplicationFollower;
6460///
6461/// let mut follower = ReplicationFollower::new("http://leader:8453", "/local/copy")?;
6462/// follower.sync(); // fetches and applies all new records since last sync
6463/// # Ok::<(), mongreldb_client::ClientError>(())
6464/// ```
6465pub struct ReplicationFollower {
6466    leader_url: String,
6467    local_path: std::path::PathBuf,
6468    client: reqwest::blocking::Client,
6469    last_epoch: u64,
6470    bearer_token: Option<SecretString>,
6471    basic_auth: Option<(String, SecretString)>,
6472    local_passphrase: Option<SecretString>,
6473    local_credentials: Option<(String, SecretString)>,
6474}
6475
6476impl std::fmt::Debug for ReplicationFollower {
6477    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6478        formatter
6479            .debug_struct("ReplicationFollower")
6480            .field("leader_url", &self.leader_url)
6481            .field("local_path", &self.local_path)
6482            .field("last_epoch", &self.last_epoch)
6483            .field(
6484                "bearer_token",
6485                &self.bearer_token.as_ref().map(|_| "[REDACTED]"),
6486            )
6487            .field(
6488                "basic_auth",
6489                &self
6490                    .basic_auth
6491                    .as_ref()
6492                    .map(|(username, _)| (username, "[REDACTED]")),
6493            )
6494            .field(
6495                "local_passphrase",
6496                &self.local_passphrase.as_ref().map(|_| "[REDACTED]"),
6497            )
6498            .field(
6499                "local_credentials",
6500                &self
6501                    .local_credentials
6502                    .as_ref()
6503                    .map(|(username, _)| (username, "[REDACTED]")),
6504            )
6505            .finish()
6506    }
6507}
6508
6509impl ReplicationFollower {
6510    /// Create a follower. `leader_url` is the daemon base URL; `local_path` is
6511    /// the local database directory to sync into.
6512    pub fn new(leader_url: &str, local_path: impl AsRef<std::path::Path>) -> ClientResult<Self> {
6513        let leader_url = sanitized_base_url(leader_url).ok_or_else(|| {
6514            ClientError::Transport(
6515                "invalid leader URL: expected http(s) URL without credentials, query, or fragment"
6516                    .into(),
6517            )
6518        })?;
6519        let local_path = local_path.as_ref().to_path_buf();
6520        let last_epoch = mongreldb_core::replica_epoch(&local_path).unwrap_or(0);
6521        Ok(Self {
6522            leader_url,
6523            local_path,
6524            client: reqwest::blocking::Client::new(),
6525            last_epoch,
6526            bearer_token: None,
6527            basic_auth: None,
6528            local_passphrase: None,
6529            local_credentials: None,
6530        })
6531    }
6532
6533    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
6534        self.bearer_token = Some(token.into().into());
6535        self
6536    }
6537
6538    pub fn with_basic_auth(
6539        mut self,
6540        username: impl Into<String>,
6541        password: impl Into<String>,
6542    ) -> Self {
6543        self.basic_auth = Some((username.into(), password.into().into()));
6544        self
6545    }
6546
6547    pub fn with_local_encryption_passphrase(mut self, passphrase: impl Into<String>) -> Self {
6548        self.local_passphrase = Some(passphrase.into().into());
6549        self
6550    }
6551
6552    pub fn with_local_credentials(
6553        mut self,
6554        username: impl Into<String>,
6555        password: impl Into<String>,
6556    ) -> Self {
6557        self.local_credentials = Some((username.into(), password.into().into()));
6558        self
6559    }
6560
6561    /// Bootstrap when needed, fetch complete committed transactions, append
6562    /// them durably, recover the local database, then advance the watermark.
6563    pub fn sync(&mut self) -> Result<usize, String> {
6564        let legacy_replica_needs_snapshot = mongreldb_core::is_replica(&self.local_path)
6565            && !self.local_path.join("_meta/replication_source_id").exists();
6566        if !self.local_path.join("CATALOG").exists() || legacy_replica_needs_snapshot {
6567            self.ensure_bootstrap_destination_is_owned_or_empty()?;
6568            self.bootstrap()?;
6569        } else if !mongreldb_core::is_replica(&self.local_path) {
6570            return Err(format!(
6571                "refusing to overwrite non-replica database at {}",
6572                self.local_path.display()
6573            ));
6574        }
6575
6576        let mut resp = self.fetch_wal()?;
6577        if resp.status() == reqwest::StatusCode::CONFLICT {
6578            self.validate_snapshot_required_response(resp)?;
6579            self.bootstrap()?;
6580            resp = self.fetch_wal()?;
6581        }
6582        if !resp.status().is_success() {
6583            return Err(format!("leader returned {}", resp.status()));
6584        }
6585        let from_epoch = replication_u64_header(&resp, "x-mongreldb-from-epoch")?;
6586        let leader_epoch = replication_u64_header(&resp, "x-mongreldb-current-epoch")?;
6587        let source_id = replication_digest_header(&resp, "x-mongreldb-source-id")?;
6588        let commit_count = replication_u64_header(&resp, "x-mongreldb-commit-count")?;
6589        let records_sha256 = replication_digest_header(&resp, "x-mongreldb-records-sha256")?;
6590        let earliest_epoch = resp
6591            .headers()
6592            .get("x-mongreldb-earliest-epoch")
6593            .map(|value| {
6594                value
6595                    .to_str()
6596                    .map_err(|error| format!("invalid x-mongreldb-earliest-epoch: {error}"))?
6597                    .parse::<u64>()
6598                    .map_err(|error| format!("invalid x-mongreldb-earliest-epoch: {error}"))
6599            })
6600            .transpose()?;
6601        let body = bounded_blocking_bytes(resp, MAX_REPLICATION_WAL_RESPONSE_BYTES)
6602            .map_err(|error| format!("failed to read WAL response: {error}"))?;
6603        let body = std::str::from_utf8(&body)
6604            .map_err(|_| "invalid WAL response: body is not UTF-8".to_owned())?;
6605        let mut records = Vec::new();
6606        for line in body.lines() {
6607            if line.trim().is_empty() {
6608                continue;
6609            }
6610            records.push(
6611                strict_roundtrip_json::<mongreldb_core::wal::Record>(
6612                    line.as_bytes(),
6613                    "WAL record from leader",
6614                )
6615                .map_err(|error| error.to_string())?,
6616            );
6617        }
6618        let record_count = records.len();
6619        let batch = mongreldb_core::ReplicationBatch::from_wire(
6620            source_id,
6621            from_epoch,
6622            leader_epoch,
6623            earliest_epoch,
6624            commit_count,
6625            records_sha256,
6626            records,
6627        );
6628
6629        let local = self.open_local()?;
6630        let applied_epoch = local
6631            .append_replication_batch(&batch)
6632            .map_err(|error| error.to_string())?;
6633        if record_count == 0 {
6634            return Ok(0);
6635        }
6636        drop(local);
6637        let recovered = self.open_local()?;
6638        if recovered.visible_epoch().0 < applied_epoch {
6639            return Err(format!(
6640                "replica recovery stopped at epoch {}, expected {applied_epoch}",
6641                recovered.visible_epoch().0
6642            ));
6643        }
6644        drop(recovered);
6645        mongreldb_core::write_replica_epoch(&self.local_path, applied_epoch)
6646            .map_err(|error| error.to_string())?;
6647        self.last_epoch = applied_epoch;
6648        Ok(record_count)
6649    }
6650
6651    pub fn bootstrap(&mut self) -> Result<(), String> {
6652        self.ensure_bootstrap_destination_is_owned_or_empty()?;
6653        let url = format!("{}/replication/snapshot", self.leader_url);
6654        let response = self
6655            .request(&url)
6656            .send()
6657            .map_err(|error| format!("failed to fetch replication snapshot: {error}"))?;
6658        if !response.status().is_success() {
6659            return Err(format!(
6660                "leader snapshot endpoint returned {}",
6661                response.status()
6662            ));
6663        }
6664        let response_source_id = replication_digest_header(&response, "x-mongreldb-source-id")?;
6665        let response_epoch = replication_u64_header(&response, "x-mongreldb-current-epoch")?;
6666        let bytes = bounded_blocking_bytes(response, MAX_REPLICATION_SNAPSHOT_BYTES)
6667            .map_err(|error| format!("failed to read replication snapshot: {error}"))?;
6668        let snapshot = mongreldb_core::ReplicationSnapshot::decode(&bytes)
6669            .map_err(|error| error.to_string())?;
6670        if snapshot.source_id() != response_source_id {
6671            return Err("replication snapshot source header does not match snapshot".into());
6672        }
6673        if snapshot.epoch() != response_epoch {
6674            return Err("replication snapshot epoch header does not match snapshot".into());
6675        }
6676        let mut minimum_epoch = self
6677            .last_epoch
6678            .max(mongreldb_core::replica_epoch(&self.local_path).unwrap_or(0));
6679        if self.local_path.join("CATALOG").exists() {
6680            let local = self.open_local()?;
6681            minimum_epoch = minimum_epoch.max(local.visible_epoch().0);
6682        }
6683        if snapshot.epoch() < minimum_epoch {
6684            return Err(format!(
6685                "refusing replication snapshot epoch {} older than local epoch {minimum_epoch}",
6686                snapshot.epoch()
6687            ));
6688        }
6689        snapshot
6690            .install_validated(&self.local_path, |stage| {
6691                let database = self
6692                    .open_path(stage)
6693                    .map_err(mongreldb_core::MongrelError::Other)?;
6694                drop(database);
6695                Ok(())
6696            })
6697            .map_err(|error| error.to_string())?;
6698        self.last_epoch = snapshot.epoch();
6699        Ok(())
6700    }
6701
6702    fn ensure_bootstrap_destination_is_owned_or_empty(&self) -> Result<(), String> {
6703        let metadata = match std::fs::symlink_metadata(&self.local_path) {
6704            Ok(metadata) => metadata,
6705            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
6706            Err(error) => {
6707                return Err(format!(
6708                    "failed to inspect replication destination {}: {error}",
6709                    self.local_path.display()
6710                ))
6711            }
6712        };
6713        if metadata.file_type().is_symlink() || !metadata.is_dir() {
6714            return Err(format!(
6715                "replication destination is not a directory: {}",
6716                self.local_path.display()
6717            ));
6718        }
6719        if mongreldb_core::is_replica(&self.local_path) {
6720            return Ok(());
6721        }
6722        let mut entries = std::fs::read_dir(&self.local_path).map_err(|error| {
6723            format!(
6724                "failed to inspect replication destination {}: {error}",
6725                self.local_path.display()
6726            )
6727        })?;
6728        if entries
6729            .next()
6730            .transpose()
6731            .map_err(|error| error.to_string())?
6732            .is_some()
6733        {
6734            return Err(format!(
6735                "refusing to overwrite non-replica directory at {}",
6736                self.local_path.display()
6737            ));
6738        }
6739        drop(entries);
6740        std::fs::remove_dir(&self.local_path).map_err(|error| {
6741            format!(
6742                "failed to claim empty replication destination {}: {error}",
6743                self.local_path.display()
6744            )
6745        })?;
6746        Ok(())
6747    }
6748
6749    fn validate_snapshot_required_response(
6750        &self,
6751        response: reqwest::blocking::Response,
6752    ) -> Result<(), String> {
6753        let from_epoch = replication_u64_header(&response, "x-mongreldb-from-epoch")?;
6754        let current_epoch = replication_u64_header(&response, "x-mongreldb-current-epoch")?;
6755        let source_id = replication_digest_header(&response, "x-mongreldb-source-id")?;
6756        let status = response
6757            .headers()
6758            .get("x-mongreldb-replication-status")
6759            .and_then(|value| value.to_str().ok());
6760        if status != Some("snapshot-required") {
6761            return Err("leader returned an unrecognized replication conflict".into());
6762        }
6763        if self.local_path.join("CATALOG").exists() {
6764            let local_source = mongreldb_core::replica_source_id(&self.local_path)
6765                .map_err(|error| error.to_string())?;
6766            if source_id != local_source {
6767                return Err("replication conflict came from a different source".into());
6768            }
6769        }
6770        replication_u64_header(&response, "x-mongreldb-commit-count")?;
6771        replication_digest_header(&response, "x-mongreldb-records-sha256")?;
6772        if from_epoch != self.last_epoch || current_epoch < self.last_epoch {
6773            return Err(format!(
6774                "invalid replication snapshot requirement: from epoch {from_epoch}, current epoch {current_epoch}, local epoch {}",
6775                self.last_epoch
6776            ));
6777        }
6778        let body = bounded_blocking_bytes(response, MAX_CONTROL_RESPONSE_BYTES)
6779            .map_err(|error| format!("failed to read replication conflict response: {error}"))?;
6780        if body != b"replication snapshot required: WAL retention gap or spilled run" {
6781            return Err("leader returned an unrecognized replication conflict".into());
6782        }
6783        Ok(())
6784    }
6785
6786    fn fetch_wal(&self) -> Result<reqwest::blocking::Response, String> {
6787        let url = format!("{}/wal/stream?since={}", self.leader_url, self.last_epoch);
6788        self.request(&url)
6789            .send()
6790            .map_err(|error| format!("failed to connect to leader: {error}"))
6791    }
6792
6793    fn request(&self, url: &str) -> reqwest::blocking::RequestBuilder {
6794        let request = self.client.get(url);
6795        if let Some(token) = &self.bearer_token {
6796            request.bearer_auth(token.expose_secret())
6797        } else if let Some((username, password)) = &self.basic_auth {
6798            request.basic_auth(username, Some(password.expose_secret()))
6799        } else {
6800            request
6801        }
6802    }
6803
6804    fn open_local(&self) -> Result<mongreldb_core::Database, String> {
6805        self.open_path(&self.local_path)
6806    }
6807
6808    fn open_path(&self, path: &std::path::Path) -> Result<mongreldb_core::Database, String> {
6809        let result = match (&self.local_passphrase, &self.local_credentials) {
6810            (Some(passphrase), Some((username, password))) => {
6811                mongreldb_core::Database::open_encrypted_with_credentials(
6812                    path,
6813                    passphrase.expose_secret(),
6814                    username,
6815                    password.expose_secret(),
6816                )
6817            }
6818            (Some(passphrase), None) => {
6819                mongreldb_core::Database::open_encrypted(path, passphrase.expose_secret())
6820            }
6821            (None, Some((username, password))) => mongreldb_core::Database::open_with_credentials(
6822                path,
6823                username,
6824                password.expose_secret(),
6825            ),
6826            (None, None) => mongreldb_core::Database::open(path),
6827        };
6828        result.map_err(|error| format!("failed to open local replica: {error}"))
6829    }
6830
6831    /// The highest leader commit epoch applied so far.
6832    pub fn last_epoch(&self) -> u64 {
6833        self.last_epoch
6834    }
6835
6836    /// Backward-compatible alias for [`Self::last_epoch`].
6837    pub fn last_seq(&self) -> u64 {
6838        self.last_epoch
6839    }
6840}
6841
6842fn replication_u64_header(
6843    response: &reqwest::blocking::Response,
6844    name: &str,
6845) -> Result<u64, String> {
6846    response
6847        .headers()
6848        .get(name)
6849        .ok_or_else(|| format!("leader response missing {name}"))?
6850        .to_str()
6851        .map_err(|error| format!("invalid {name}: {error}"))?
6852        .parse()
6853        .map_err(|error| format!("invalid {name}: {error}"))
6854}
6855
6856fn replication_digest_header(
6857    response: &reqwest::blocking::Response,
6858    name: &str,
6859) -> Result<[u8; 32], String> {
6860    let value = response
6861        .headers()
6862        .get(name)
6863        .ok_or_else(|| format!("leader response missing {name}"))?
6864        .to_str()
6865        .map_err(|error| format!("invalid {name}: {error}"))?;
6866    if value.len() != 64 {
6867        return Err(format!(
6868            "invalid {name}: expected 64 hexadecimal characters"
6869        ));
6870    }
6871    let mut digest = [0_u8; 32];
6872    for (index, byte) in digest.iter_mut().enumerate() {
6873        *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
6874            .map_err(|error| format!("invalid {name}: {error}"))?;
6875    }
6876    Ok(digest)
6877}