Skip to main content

mongreldb_server/
lib.rs

1//! mongreldb-server — a long-lived process holding a multi-table `Database`
2//! open, serving SQL + table-qualified native APIs over HTTP.
3//!
4//! Endpoints:
5//!   GET    /health                    → 200 OK
6//!   GET    /tables                    → ["t1", "t2", ...]
7//!   POST   /tables                    → create table
8//!   DELETE /tables/{name}              → drop table
9//!   POST   /tables/{name}/put          → upsert one row
10//!   POST   /tables/{name}/count        → { "count": N }
11//!   POST   /tables/{name}/commit       → { "epoch": N }
12//!   POST   /sql                       → Arrow IPC bytes
13//!   POST   /txn                       → atomic cross-table transaction
14//!
15//! Usage: `mongreldb-server <db_dir> [port]`
16
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19
20use axum::extract::{Path, State};
21use axum::http::header;
22use axum::http::StatusCode;
23use axum::response::{IntoResponse, Response};
24use axum::routing::{get, post};
25use axum::Json;
26use mongreldb_core::schema::{Schema, TypeId};
27use mongreldb_core::{CancellationReason, Database, Value};
28use mongreldb_query::{
29    CancelOutcome, ExternalTableModule, ManagedQueryBatches, MongrelSession, QueryId,
30    RegisteredQueryGuard, RegisteredSqlQuery, SqlQueryOptions, SqlQueryPhase, SqlQueryRegistry,
31    SqlStreamCompletion,
32};
33use serde::{Deserialize, Serialize};
34use serde_json::json;
35
36mod audit;
37mod kit;
38mod metrics;
39mod procedure;
40mod sessions;
41mod trigger;
42
43pub use sessions::{spawn_session_reaper, SessionStore};
44
45/// Map an engine error to the appropriate HTTP status code for defense-in-depth.
46/// Auth errors get 401/403; everything else stays 500. This ensures that even
47/// after the HTTP auth middleware lets a request through, the storage layer's
48/// permission checks surface as the right status (not a generic 500).
49fn status_for_error(e: &mongreldb_core::MongrelError) -> StatusCode {
50    use mongreldb_core::MongrelError;
51    match e {
52        MongrelError::AuthRequired | MongrelError::InvalidCredentials { .. } => {
53            StatusCode::UNAUTHORIZED
54        }
55        MongrelError::AuthNotRequired => StatusCode::BAD_REQUEST,
56        MongrelError::PermissionDenied { .. } => StatusCode::FORBIDDEN,
57        MongrelError::InvalidArgument(_) => StatusCode::CONFLICT,
58        MongrelError::Conflict(_) => StatusCode::CONFLICT,
59        MongrelError::ReadOnlyReplica => StatusCode::CONFLICT,
60        MongrelError::NotFound(_) => StatusCode::NOT_FOUND,
61        MongrelError::DeadlineExceeded => StatusCode::GATEWAY_TIMEOUT,
62        MongrelError::WorkBudgetExceeded => StatusCode::TOO_MANY_REQUESTS,
63        MongrelError::Cancelled => StatusCode::from_u16(499).expect("499 is valid"),
64        MongrelError::CursorStale(_) => StatusCode::CONFLICT,
65        MongrelError::CursorExpired => StatusCode::GONE,
66        _ => StatusCode::INTERNAL_SERVER_ERROR,
67    }
68}
69
70/// Map a query-layer error (which wraps engine errors via `Core(...)`) to the
71/// appropriate HTTP status code.
72fn status_for_query_error(e: &mongreldb_query::MongrelQueryError) -> StatusCode {
73    use mongreldb_query::MongrelQueryError;
74    match e {
75        MongrelQueryError::Core(core) => status_for_error(core),
76        MongrelQueryError::DeadlineExceeded { .. } => StatusCode::GATEWAY_TIMEOUT,
77        MongrelQueryError::QueryCancelled { .. } => {
78            StatusCode::from_u16(499).expect("499 is valid")
79        }
80        MongrelQueryError::QueryIdConflict { .. } => StatusCode::CONFLICT,
81        MongrelQueryError::QueryRegistryFull => StatusCode::SERVICE_UNAVAILABLE,
82        MongrelQueryError::TransactionAborted => StatusCode::CONFLICT,
83        MongrelQueryError::CommitOutcome { .. } => StatusCode::CONFLICT,
84        _ => StatusCode::INTERNAL_SERVER_ERROR,
85    }
86}
87
88/// Extractor that pulls the authenticated [`mongreldb_core::Principal`] (if the
89/// auth middleware injected one) from request extensions without erroring when
90/// absent (e.g. token-authenticated requests carry no `Principal`).
91struct OptionalPrincipal(Option<mongreldb_core::Principal>);
92
93impl<S> axum::extract::FromRequestParts<S> for OptionalPrincipal
94where
95    S: Send + Sync,
96{
97    type Rejection = std::convert::Infallible;
98
99    async fn from_request_parts(
100        parts: &mut axum::http::request::Parts,
101        _state: &S,
102    ) -> Result<Self, Self::Rejection> {
103        Ok(OptionalPrincipal(
104            parts.extensions.get::<mongreldb_core::Principal>().cloned(),
105        ))
106    }
107}
108
109struct AppState {
110    db: Arc<Database>,
111    idem: kit::IdempotencyStore,
112    external_modules: Vec<Arc<dyn ExternalTableModule>>,
113    auth_token: Option<String>,
114    /// When true, authenticate via catalog users (HTTP Basic auth).
115    user_auth: bool,
116    /// Daemon-wide Prometheus-style counters, shared by all handlers.
117    metrics: Arc<metrics::Metrics>,
118    /// `/sql` requests slower than this are logged as slow queries.
119    slow_query_threshold: std::time::Duration,
120    /// Bounded security audit log (auth + DDL/privilege events).
121    audit: Arc<audit::AuditLog>,
122    /// Token-keyed pool of live sessions for cross-request interactive
123    /// transactions (`X-Session-ID` on `/sql`).
124    sessions: Arc<sessions::SessionStore>,
125    /// Bounds CPU-heavy AI work submitted to Tokio's blocking pool.
126    ai_semaphore: Arc<tokio::sync::Semaphore>,
127    /// Process-wide SQL registry. Cancellation never takes a session lock.
128    query_registry: Arc<SqlQueryRegistry>,
129    /// Admission control for ordinary SQL, separate from AI workers.
130    sql_semaphore: Arc<tokio::sync::Semaphore>,
131    sql_default_timeout: std::time::Duration,
132    sql_max_timeout: std::time::Duration,
133    sql_cancel_grace: std::time::Duration,
134    sql_max_output_bytes: usize,
135    sql_max_output_rows: usize,
136    accepting_sql: Arc<AtomicBool>,
137    /// Process-local HMAC key for stateless Kit cursors. Restart invalidates cursors.
138    cursor_mac_key: [u8; 32],
139}
140
141/// Handle retained by the daemon to coordinate graceful SQL shutdown without
142/// coupling signal handling to Axum's router internals.
143#[derive(Clone)]
144pub struct ServerControl {
145    query_registry: Arc<SqlQueryRegistry>,
146    sessions: Arc<sessions::SessionStore>,
147    accepting_sql: Arc<AtomicBool>,
148    cancel_grace: std::time::Duration,
149    metrics: Arc<metrics::Metrics>,
150}
151
152impl ServerControl {
153    pub async fn shutdown(&self) -> usize {
154        self.accepting_sql.store(false, Ordering::Release);
155        self.query_registry
156            .cancel_all(CancellationReason::ServerShutdown);
157        let deadline = tokio::time::Instant::now() + self.cancel_grace;
158        while self.query_registry.active_count() > 0 && tokio::time::Instant::now() < deadline {
159            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
160        }
161        self.sessions.close_all();
162        let stuck_queries = self.query_registry.active_statuses();
163        for status in &stuck_queries {
164            eprintln!(
165                "[sql-cancel-stuck] query_id={} phase={}",
166                status.query_id,
167                query_phase_name(status.phase)
168            );
169        }
170        let stuck = stuck_queries.len();
171        self.metrics.add_sql_stuck_after_cancel(stuck);
172        stuck
173    }
174}
175
176pub fn build_app(db: Arc<Database>) -> axum::Router {
177    build_app_with_config(
178        db,
179        std::iter::empty::<Arc<dyn ExternalTableModule>>(),
180        None,
181        None,
182    )
183}
184
185pub fn build_app_with_external_modules(
186    db: Arc<Database>,
187    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
188) -> axum::Router {
189    build_app_with_config(db, external_modules, None, None)
190}
191
192/// Build the daemon router with optional auth token and max-connections limit.
193pub fn build_app_with_config(
194    db: Arc<Database>,
195    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
196    auth_token: Option<String>,
197    max_connections: Option<usize>,
198) -> axum::Router {
199    build_app_full(db, external_modules, auth_token, max_connections, false)
200}
201
202/// Build the daemon router with full auth configuration including user-based auth.
203/// Sessions are enabled with a default capacity (256) and idle timeout (300 s).
204pub fn build_app_full(
205    db: Arc<Database>,
206    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
207    auth_token: Option<String>,
208    max_connections: Option<usize>,
209    user_auth: bool,
210) -> axum::Router {
211    let sessions = Arc::new(sessions::SessionStore::new(
212        default_max_sessions(),
213        default_session_idle_timeout(),
214    ));
215    build_app_with_sessions(
216        db,
217        external_modules,
218        auth_token,
219        max_connections,
220        user_auth,
221        sessions,
222    )
223}
224
225/// Build the daemon router with an explicit, externally-owned session store.
226/// The caller (typically `main`) keeps the `Arc<SessionStore>` so it can spawn
227/// the idle reaper against the same map the handlers use.
228pub fn build_app_with_sessions(
229    db: Arc<Database>,
230    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
231    auth_token: Option<String>,
232    max_connections: Option<usize>,
233    user_auth: bool,
234    sessions: Arc<sessions::SessionStore>,
235) -> axum::Router {
236    build_app_with_sessions_and_control(
237        db,
238        external_modules,
239        auth_token,
240        max_connections,
241        user_auth,
242        sessions,
243    )
244    .0
245}
246
247/// Build the daemon router and return a handle for graceful query shutdown.
248pub fn build_app_with_sessions_and_control(
249    db: Arc<Database>,
250    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
251    auth_token: Option<String>,
252    max_connections: Option<usize>,
253    user_auth: bool,
254    sessions: Arc<sessions::SessionStore>,
255) -> (axum::Router, ServerControl) {
256    db.set_replication_wal_retention_segments(default_replication_wal_segments());
257    if let Err(error) = db.set_history_retention_epochs(default_history_retention_epochs()) {
258        eprintln!("[history] failed to configure retention: {error}");
259    }
260    let mut cursor_mac_key = [0u8; 32];
261    mongreldb_core::encryption::fill_random(&mut cursor_mac_key);
262    let max_active_queries = default_sql_max_active_queries();
263    let query_registry = Arc::new(SqlQueryRegistry::new(
264        max_active_queries,
265        max_active_queries.saturating_mul(2),
266        2 * 1024 * 1024,
267        default_sql_finished_query_ttl(),
268    ));
269    let accepting_sql = Arc::new(AtomicBool::new(true));
270    let sql_cancel_grace = default_sql_cancel_grace();
271    let sql_max_timeout = default_sql_max_timeout();
272    let sql_default_timeout = default_sql_default_timeout().min(sql_max_timeout);
273    let metrics = Arc::new(metrics::Metrics::default());
274    let server_control = ServerControl {
275        query_registry: Arc::clone(&query_registry),
276        sessions: Arc::clone(&sessions),
277        accepting_sql: Arc::clone(&accepting_sql),
278        cancel_grace: sql_cancel_grace,
279        metrics: Arc::clone(&metrics),
280    };
281    let state = Arc::new(AppState {
282        idem: kit::IdempotencyStore::new(db.root()),
283        db,
284        external_modules: external_modules.into_iter().collect(),
285        auth_token,
286        user_auth,
287        metrics,
288        slow_query_threshold: metrics::slow_query_threshold(),
289        audit: Arc::new(audit::AuditLog::new(8192)),
290        sessions,
291        ai_semaphore: Arc::new(tokio::sync::Semaphore::new(default_ai_max_concurrent())),
292        query_registry,
293        sql_semaphore: Arc::new(tokio::sync::Semaphore::new(default_sql_max_concurrent())),
294        sql_default_timeout,
295        sql_max_timeout,
296        sql_cancel_grace,
297        sql_max_output_bytes: default_sql_max_output_bytes(),
298        sql_max_output_rows: default_sql_max_output_rows(),
299        accepting_sql,
300        cursor_mac_key,
301    });
302    let router = axum::Router::new()
303        .route("/health", get(health))
304        .route("/capabilities", get(capabilities))
305        .route(
306            "/history/retention",
307            get(history_retention).put(set_history_retention),
308        )
309        .route("/metrics", get(metrics_handler))
310        .route("/audit", get(audit_handler))
311        .route("/tables", get(list_tables).post(create_table))
312        .route("/tables/{name}", axum::routing::delete(drop_table))
313        .route("/tables/{name}/put", post(put_row))
314        .route("/tables/{name}/count", get(count))
315        .route("/tables/{name}/commit", post(commit))
316        .route("/sql", post(sql))
317        .route("/queries/{query_id}", get(query_status))
318        .route("/queries/{query_id}/cancel", post(cancel_query))
319        .route("/txn", post(txn))
320        .route("/sessions", post(create_session))
321        .route("/sessions/{id}", axum::routing::delete(close_session))
322        .route("/sessions/{id}/prepare", post(prepare_statement))
323        .route("/sessions/{id}/execute", post(execute_statement))
324        .route(
325            "/sessions/{id}/statements/{name}",
326            axum::routing::delete(deallocate_statement),
327        )
328        .route("/procedures", get(procedure::list).post(procedure::create))
329        .route(
330            "/procedures/{name}",
331            get(procedure::describe)
332                .put(procedure::replace)
333                .delete(procedure::drop_procedure),
334        )
335        .route("/procedures/{name}/call", post(procedure::call))
336        .route("/triggers", get(trigger::list).post(trigger::create))
337        .route(
338            "/triggers/{name}",
339            get(trigger::describe)
340                .put(trigger::replace)
341                .delete(trigger::drop_trigger),
342        )
343        // Typed Kit-aware surface (authoritative validation + constraints).
344        .route("/kit/schema", get(kit::schema_all))
345        .route("/kit/schema/{table}", get(kit::schema_one))
346        .route("/kit/txn", post(kit::kit_txn))
347        .route("/kit/query", post(kit::kit_query))
348        .route("/kit/retrieve", post(kit::kit_retrieve))
349        .route("/kit/ann_rerank", post(kit::kit_ann_rerank))
350        .route("/kit/ai/metrics", get(kit::kit_ai_metrics))
351        .route("/kit/set_similarity", post(kit::kit_set_similarity))
352        .route("/kit/search", post(kit::kit_search))
353        .route("/kit/create_table", post(kit::kit_create_table))
354        .route("/kit/procedures/{name}/call", post(procedure::kit_call))
355        .route("/compact", post(compact_all))
356        .route("/tables/{name}/compact", post(compact_table))
357        .route("/wal/stream", get(wal_stream))
358        .route("/replication/snapshot", get(replication_snapshot))
359        .route("/events", get(events_stream))
360        .with_state(state.clone());
361
362    // Apply auth middleware if token auth or user auth is enabled.
363    let router = if state.auth_token.is_some() || state.user_auth {
364        router.layer(axum::middleware::from_fn_with_state(
365            state.clone(),
366            auth_middleware,
367        ))
368    } else {
369        router
370    };
371
372    // Apply connection limit if configured.
373    let router = if let Some(max) = max_connections {
374        router.layer(tower::limit::ConcurrencyLimitLayer::new(max))
375    } else {
376        router
377    };
378    (router, server_control)
379}
380
381/// Auth middleware supporting three modes:
382/// 1. **Token** (`--auth-token <token>`): checks `Authorization: Bearer <token>`.
383/// 2. **User auth** (`--auth-users`): checks `Authorization: Basic <base64(user:pass)>`
384///    against catalog users (Argon2id-verified). Injects a `Principal` into
385///    request extensions.
386/// 3. **Both**: token OR valid user credentials accepted.
387async fn auth_middleware(
388    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
389    mut req: axum::extract::Request,
390    next: axum::middleware::Next,
391) -> Result<axum::response::Response, axum::http::StatusCode> {
392    let header = req
393        .headers()
394        .get("authorization")
395        .and_then(|v| v.to_str().ok())
396        .unwrap_or("");
397
398    // Track the attempted identity + failure reason so EVERY 401 path emits
399    // exactly one `login.fail` audit event (missing, malformed, wrong token,
400    // wrong password are all logged — no unauthenticated probe goes unrecorded).
401    let mut attempted = String::new();
402    let mut fail_reason = "no credentials provided".to_string();
403
404    // Mode 1: Token auth (Bearer).
405    if let Some(token) = &state.auth_token {
406        if let Some(provided) = header.strip_prefix("Bearer ") {
407            attempted = "token".to_string();
408            if provided == token {
409                state
410                    .audit
411                    .record("token", "login.ok", "bearer token accepted");
412                return Ok(next.run(req).await);
413            }
414            fail_reason = "invalid bearer token".to_string();
415        }
416    }
417
418    // Mode 2: User auth (Basic).
419    if state.user_auth {
420        if let Some(encoded) = header.strip_prefix("Basic ") {
421            if let Ok(decoded) = base64_decode(encoded) {
422                if let Ok(creds) = std::str::from_utf8(&decoded) {
423                    if let Some((username, password)) = creds.split_once(':') {
424                        attempted = username.to_string();
425                        if let Ok(Some(_user)) = state.db.verify_user(username, password) {
426                            state
427                                .audit
428                                .record(username, "login.ok", "basic credentials accepted");
429                            // Inject the principal for permission checks.
430                            if let Some(principal) = state.db.resolve_principal(username) {
431                                req.extensions_mut().insert(principal);
432                            }
433                            return Ok(next.run(req).await);
434                        }
435                        fail_reason = "invalid basic credentials".to_string();
436                    } else {
437                        fail_reason = "malformed basic credentials (no ':')".to_string();
438                    }
439                } else {
440                    fail_reason = "malformed basic credentials (non-utf8)".to_string();
441                }
442            } else {
443                fail_reason = "malformed basic credentials (bad base64)".to_string();
444            }
445        }
446    }
447
448    let who = if attempted.is_empty() {
449        "anonymous"
450    } else {
451        attempted.as_str()
452    };
453    state.audit.record(who, "login.fail", fail_reason);
454    Err(axum::http::StatusCode::UNAUTHORIZED)
455}
456
457/// Minimal Base64 decoder (no extra dep).
458fn base64_decode(input: &str) -> Result<Vec<u8>, ()> {
459    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
460    let input: Vec<u8> = input
461        .bytes()
462        .filter(|&b| b != b'\n' && b != b'\r' && b != b' ')
463        .collect();
464    let mut out = Vec::with_capacity(input.len() * 3 / 4);
465    let mut buf = 0u32;
466    let mut bits = 0u32;
467    for &b in &input {
468        if b == b'=' {
469            break;
470        }
471        let val = TABLE.iter().position(|&t| t == b).ok_or(())? as u32;
472        buf = (buf << 6) | val;
473        bits += 6;
474        if bits >= 8 {
475            bits -= 8;
476            out.push((buf >> bits) as u8);
477        }
478    }
479    Ok(out)
480}
481
482/// `GET /wal/stream?since=<epoch>` — return complete committed WAL
483/// transactions after the follower epoch. A 409 response means retained WAL
484/// cannot close the gap and the follower must fetch `/replication/snapshot`.
485/// Records are newline-delimited JSON.
486/// newline-delimited JSON for replication followers. Each line is a JSON
487/// object `{ "seq": N, "txn_id": N, "op": {...} }`.
488async fn wal_stream(
489    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
490    OptionalPrincipal(principal): OptionalPrincipal,
491    axum::extract::Query(params): axum::extract::Query<WalStreamParams>,
492) -> Result<Response, StatusCode> {
493    state
494        .db
495        .require_for(
496            request_principal(&state, &principal).as_ref(),
497            &mongreldb_core::Permission::Admin,
498        )
499        .map_err(|error| status_for_error(&error))?;
500    let since = params.since.unwrap_or(0);
501    let db = Arc::clone(&state.db);
502    let batch = tokio::task::spawn_blocking(move || db.replication_batch_since(since))
503        .await
504        .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
505    let batch = batch.map_err(|e| {
506        eprintln!("wal_stream error: {e}");
507        StatusCode::INTERNAL_SERVER_ERROR
508    })?;
509    if batch.requires_snapshot {
510        let mut response = (
511            StatusCode::CONFLICT,
512            "replication snapshot required: WAL retention gap or spilled run",
513        )
514            .into_response();
515        set_replication_headers(&mut response, batch.current_epoch, batch.earliest_epoch);
516        return Ok(response);
517    }
518    let mut body = String::new();
519    for record in &batch.records {
520        let json = serde_json::to_string(record).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
521        body.push_str(&json);
522        body.push('\n');
523    }
524    let mut response = (
525        [
526            (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
527            (header::CACHE_CONTROL, "no-cache".to_string()),
528        ],
529        body,
530    )
531        .into_response();
532    set_replication_headers(&mut response, batch.current_epoch, batch.earliest_epoch);
533    Ok(response)
534}
535
536async fn replication_snapshot(
537    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
538    OptionalPrincipal(principal): OptionalPrincipal,
539) -> Response {
540    if let Err(error) = state.db.require_for(
541        request_principal(&state, &principal).as_ref(),
542        &mongreldb_core::Permission::Admin,
543    ) {
544        return (status_for_error(&error), error.to_string()).into_response();
545    }
546    let db = Arc::clone(&state.db);
547    let snapshot = match tokio::task::spawn_blocking(move || db.replication_snapshot()).await {
548        Ok(Ok(snapshot)) => snapshot,
549        Ok(Err(error)) => {
550            return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
551        }
552        Err(error) => {
553            return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
554        }
555    };
556    let epoch = snapshot.epoch();
557    // ponytail: bootstrap buffers one image; add framed file streaming when
558    // real snapshot sizes make this memory ceiling measurable.
559    match snapshot.encode() {
560        Ok(bytes) => {
561            let mut response =
562                ([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response();
563            set_replication_headers(&mut response, epoch, None);
564            response
565        }
566        Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
567    }
568}
569
570fn set_replication_headers(response: &mut Response, current: u64, earliest: Option<u64>) {
571    response.headers_mut().insert(
572        "x-mongreldb-current-epoch",
573        current.to_string().parse().unwrap(),
574    );
575    if let Some(earliest) = earliest {
576        response.headers_mut().insert(
577            "x-mongreldb-earliest-epoch",
578            earliest.to_string().parse().unwrap(),
579        );
580    }
581}
582
583#[derive(serde::Deserialize)]
584struct WalStreamParams {
585    since: Option<u64>,
586}
587
588/// `GET /events` — long-lived SSE for durable WAL-backed row changes plus
589/// ephemeral SQL NOTIFY messages. `Last-Event-ID` resumes from a stable
590/// `<commit_epoch>:<operation_index>` id. A retention gap returns 409 before
591/// the stream starts, or a terminal `gap` SSE if the client falls behind.
592async fn events_stream(
593    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
594    OptionalPrincipal(principal): OptionalPrincipal,
595    headers: axum::http::HeaderMap,
596) -> Result<Response, StatusCode> {
597    use axum::response::sse::{Event, KeepAlive, Sse};
598    use futures::Stream;
599    use std::collections::VecDeque;
600    use std::convert::Infallible;
601
602    state
603        .db
604        .require_for(
605            request_principal(&state, &principal).as_ref(),
606            &mongreldb_core::Permission::Admin,
607        )
608        .map_err(|error| status_for_error(&error))?;
609
610    struct State {
611        db: Arc<Database>,
612        receiver: tokio::sync::broadcast::Receiver<mongreldb_core::ChangeEvent>,
613        change_wake: tokio::sync::broadcast::Receiver<()>,
614        interval: tokio::time::Interval,
615        pending: VecDeque<mongreldb_core::ChangeEvent>,
616        last_id: Option<String>,
617        poll_now: bool,
618        done: bool,
619    }
620
621    fn event(change: mongreldb_core::ChangeEvent) -> Event {
622        let id = change.id.clone();
623        let kind = if change.op == "notify" {
624            "notify"
625        } else {
626            "change"
627        };
628        let mut event = Event::default().event(kind).data(
629            serde_json::to_string(&change)
630                .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#)),
631        );
632        if let Some(id) = id {
633            event = event.id(id);
634        }
635        event
636    }
637
638    let last_id = headers
639        .get("last-event-id")
640        .and_then(|value| value.to_str().ok())
641        .map(str::to_string);
642    let receiver = state.db.subscribe_changes();
643    let change_wake = state.db.subscribe_change_commits();
644    let db = Arc::clone(&state.db);
645    let resume = last_id.clone();
646    let initial = tokio::task::spawn_blocking(move || db.change_events_since(resume.as_deref()))
647        .await
648        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
649        .map_err(|error| match error {
650            mongreldb_core::MongrelError::InvalidArgument(_) => StatusCode::BAD_REQUEST,
651            _ => StatusCode::INTERNAL_SERVER_ERROR,
652        })?;
653    if initial.gap {
654        return Ok((
655            StatusCode::CONFLICT,
656            Json(json!({
657                "error": "cdc retention gap",
658                "earliest_epoch": initial.earliest_epoch,
659                "current_epoch": initial.current_epoch,
660            })),
661        )
662            .into_response());
663    }
664
665    let stream: std::pin::Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>> = Box::pin(
666        futures::stream::unfold(
667            State {
668                db: Arc::clone(&state.db),
669                receiver,
670                change_wake,
671                interval: tokio::time::interval(std::time::Duration::from_millis(250)),
672                pending: initial.events.into(),
673                last_id,
674                poll_now: false,
675                done: false,
676            },
677            |mut stream| async move {
678                if stream.done {
679                    return None;
680                }
681                loop {
682                    if stream.poll_now {
683                        stream.poll_now = false;
684                        let db = Arc::clone(&stream.db);
685                        let last_id = stream.last_id.clone();
686                        match tokio::task::spawn_blocking(move || {
687                            db.change_events_since(last_id.as_deref())
688                        })
689                        .await
690                        {
691                            Ok(Ok(batch)) if batch.gap => {
692                                stream.done = true;
693                                let gap = Event::default().event("gap").data(
694                                    json!({
695                                        "error": "cdc retention gap",
696                                        "earliest_epoch": batch.earliest_epoch,
697                                        "current_epoch": batch.current_epoch,
698                                    })
699                                    .to_string(),
700                                );
701                                return Some((Ok(gap), stream));
702                            }
703                            Ok(Ok(batch)) => stream.pending.extend(batch.events),
704                            Ok(Err(error)) => {
705                                stream.done = true;
706                                return Some((
707                                    Ok(Event::default().event("error").data(error.to_string())),
708                                    stream,
709                                ));
710                            }
711                            Err(error) => {
712                                stream.done = true;
713                                return Some((
714                                    Ok(Event::default().event("error").data(error.to_string())),
715                                    stream,
716                                ));
717                            }
718                        }
719                    }
720                    if let Some(change) = stream.pending.pop_front() {
721                        if let Some(id) = &change.id {
722                            stream.last_id = Some(id.clone());
723                        }
724                        return Some((Ok(event(change)), stream));
725                    }
726                    tokio::select! {
727                        received = stream.receiver.recv() => {
728                            match received {
729                                Ok(change) => return Some((Ok(event(change)), stream)),
730                                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {},
731                                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
732                                    return None;
733                                }
734                            }
735                        }
736                        received = stream.change_wake.recv() => {
737                            match received {
738                                Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
739                                    stream.poll_now = true;
740                                }
741                                Err(tokio::sync::broadcast::error::RecvError::Closed) => {}
742                            }
743                        }
744                        _ = stream.interval.tick() => {
745                            stream.poll_now = true;
746                        }
747                    }
748                }
749            },
750        ),
751    );
752
753    Ok(Sse::new(stream)
754        .keep_alive(
755            KeepAlive::new()
756                .interval(std::time::Duration::from_secs(15))
757                .text("keep-alive"),
758        )
759        .into_response())
760}
761
762/// Launch the §5.9 background auto-compaction sweep (run-count cost trigger).
763/// One OS thread, sleeping `interval` between sweeps; each tick locks each
764/// table individually and calls `Table::maybe_compact`. Best-effort: a
765/// compaction error is logged and never aborts the sweep.
766pub fn spawn_auto_compactor(db: Arc<Database>) {
767    std::thread::Builder::new()
768        .name("mongreldb-auto-compact".into())
769        .spawn(move || loop {
770            std::thread::sleep(std::time::Duration::from_secs(30));
771            for name in db.table_names() {
772                let Ok(handle) = db.table(&name) else {
773                    continue;
774                };
775                let mut t = handle.lock();
776                let before = t.run_count();
777                match t.maybe_compact() {
778                    Ok(true) => {
779                        eprintln!(
780                            "[auto-compact] {name}: {} runs -> {}",
781                            before,
782                            t.run_count()
783                        );
784                    }
785                    Ok(false) => {}
786                    Err(e) => {
787                        eprintln!("[auto-compact] {name}: compaction failed: {e}");
788                    }
789                }
790            }
791        })
792        .expect("spawn auto-compact thread");
793}
794
795async fn health() -> StatusCode {
796    StatusCode::OK
797}
798
799#[derive(Debug, Serialize)]
800struct SqlCancellationCapabilities {
801    version: u8,
802    client_query_ids: bool,
803    cancel_endpoint: bool,
804    query_status: bool,
805    stream_disconnect_cancels: bool,
806}
807
808#[derive(Debug, Serialize)]
809struct CapabilitiesResponse {
810    sql_cancellation: SqlCancellationCapabilities,
811}
812
813async fn capabilities() -> Json<CapabilitiesResponse> {
814    Json(CapabilitiesResponse {
815        sql_cancellation: SqlCancellationCapabilities {
816            version: 1,
817            client_query_ids: true,
818            cancel_endpoint: true,
819            query_status: true,
820            stream_disconnect_cancels: true,
821        },
822    })
823}
824
825#[derive(Debug, Deserialize)]
826struct HistoryRetentionRequest {
827    #[serde(default)]
828    history_retention_epochs: serde_json::Value,
829}
830
831#[derive(Debug, Serialize)]
832struct HistoryRetentionResponse {
833    history_retention_epochs: u64,
834    earliest_retained_epoch: u64,
835}
836
837fn history_retention_response(db: &Database) -> HistoryRetentionResponse {
838    HistoryRetentionResponse {
839        history_retention_epochs: db.history_retention_epochs(),
840        earliest_retained_epoch: db.earliest_retained_epoch().0,
841    }
842}
843
844/// `GET /history/retention` — inspect the durable MVCC history window.
845async fn history_retention(
846    State(state): State<Arc<AppState>>,
847    OptionalPrincipal(principal): OptionalPrincipal,
848) -> Response {
849    if let Err(error) = state.db.require_for(
850        request_principal(&state, &principal).as_ref(),
851        &mongreldb_core::Permission::Admin,
852    ) {
853        return (status_for_error(&error), error.to_string()).into_response();
854    }
855    Json(history_retention_response(&state.db)).into_response()
856}
857
858/// `PUT /history/retention` — set the durable MVCC history window.
859async fn set_history_retention(
860    State(state): State<Arc<AppState>>,
861    OptionalPrincipal(principal): OptionalPrincipal,
862    Json(request): Json<HistoryRetentionRequest>,
863) -> Response {
864    if let Err(error) = state.db.require_for(
865        request_principal(&state, &principal).as_ref(),
866        &mongreldb_core::Permission::Admin,
867    ) {
868        return (status_for_error(&error), error.to_string()).into_response();
869    }
870    let Some(epochs) = request.history_retention_epochs.as_u64() else {
871        return (
872            StatusCode::BAD_REQUEST,
873            Json(json!({"error": "history_retention_epochs must be a u64"})),
874        )
875            .into_response();
876    };
877    match state.db.set_history_retention_epochs(epochs) {
878        Ok(()) => Json(history_retention_response(&state.db)).into_response(),
879        Err(error) => (status_for_error(&error), error.to_string()).into_response(),
880    }
881}
882
883/// `GET /audit` — recent security-audit events (auth + DDL/privilege) as a JSON
884/// array, oldest-first. Subject to the same auth middleware as every other
885/// route. This is a best-effort in-memory ring buffer, not a tamper-evident
886/// log (see `audit` module docs).
887async fn audit_handler(
888    State(state): State<Arc<AppState>>,
889    OptionalPrincipal(principal): OptionalPrincipal,
890) -> Response {
891    if let Err(error) = state.db.require_for(
892        request_principal(&state, &principal).as_ref(),
893        &mongreldb_core::Permission::Admin,
894    ) {
895        return (status_for_error(&error), error.to_string()).into_response();
896    }
897    let recent = state.audit.recent();
898    Json(recent).into_response()
899}
900
901/// Default max live sessions when `--max-sessions` is not given.
902fn default_max_sessions() -> usize {
903    std::env::var("MONGRELBL_MAX_SESSIONS")
904        .ok()
905        .and_then(|v| v.parse().ok())
906        .unwrap_or(256)
907}
908
909/// Default idle-session timeout when `--session-idle-timeout` is not given.
910fn default_session_idle_timeout() -> std::time::Duration {
911    std::time::Duration::from_secs(
912        std::env::var("MONGRELBL_SESSION_IDLE_TIMEOUT_SECS")
913            .ok()
914            .and_then(|v| v.parse().ok())
915            .unwrap_or(300),
916    )
917}
918
919fn default_replication_wal_segments() -> usize {
920    let replication = std::env::var("MONGRELDB_REPLICATION_WAL_SEGMENTS")
921        .ok()
922        .and_then(|value| value.parse().ok())
923        .unwrap_or(16);
924    let cdc = std::env::var("MONGRELDB_CDC_WAL_SEGMENTS")
925        .ok()
926        .and_then(|value| value.parse().ok())
927        .unwrap_or(16);
928    replication.max(cdc)
929}
930
931fn default_history_retention_epochs() -> u64 {
932    std::env::var("MONGRELDB_HISTORY_RETENTION_EPOCHS")
933        .ok()
934        .and_then(|value| value.parse().ok())
935        .unwrap_or(1024)
936}
937
938fn default_ai_max_concurrent() -> usize {
939    std::env::var("MONGRELDB_AI_MAX_CONCURRENT")
940        .ok()
941        .and_then(|value| value.parse().ok())
942        .filter(|value| *value > 0)
943        .unwrap_or(4)
944}
945
946fn positive_env_u64(name: &str, default: u64) -> u64 {
947    std::env::var(name)
948        .ok()
949        .and_then(|value| value.parse().ok())
950        .filter(|value| *value > 0)
951        .unwrap_or(default)
952}
953
954fn positive_env_usize(name: &str, default: usize) -> usize {
955    std::env::var(name)
956        .ok()
957        .and_then(|value| value.parse().ok())
958        .filter(|value| *value > 0)
959        .unwrap_or(default)
960}
961
962fn default_sql_default_timeout() -> std::time::Duration {
963    std::time::Duration::from_millis(positive_env_u64("MONGRELDB_SQL_DEFAULT_TIMEOUT_MS", 30_000))
964}
965
966fn default_sql_max_timeout() -> std::time::Duration {
967    std::time::Duration::from_millis(positive_env_u64("MONGRELDB_SQL_MAX_TIMEOUT_MS", 300_000))
968}
969
970fn default_sql_max_concurrent() -> usize {
971    positive_env_usize(
972        "MONGRELDB_SQL_MAX_CONCURRENT",
973        std::thread::available_parallelism()
974            .map(usize::from)
975            .unwrap_or(4),
976    )
977}
978
979fn default_sql_max_active_queries() -> usize {
980    positive_env_usize("MONGRELDB_SQL_MAX_ACTIVE_QUERIES", 1_024)
981}
982
983fn default_sql_finished_query_ttl() -> std::time::Duration {
984    std::time::Duration::from_secs(positive_env_u64(
985        "MONGRELDB_SQL_FINISHED_QUERY_TTL_SECS",
986        60,
987    ))
988}
989
990fn default_sql_cancel_grace() -> std::time::Duration {
991    std::time::Duration::from_millis(positive_env_u64("MONGRELDB_SQL_CANCEL_GRACE_MS", 1_000))
992}
993
994fn default_sql_max_output_bytes() -> usize {
995    positive_env_usize("MONGRELDB_SQL_MAX_OUTPUT_BYTES", 64 * 1024 * 1024)
996}
997
998fn default_sql_max_output_rows() -> usize {
999    positive_env_usize("MONGRELDB_SQL_MAX_OUTPUT_ROWS", 1_000_000)
1000}
1001
1002/// The principal a request is attributed to, for session ownership: a resolved
1003/// user principal wins; otherwise `token` when token auth is active; else
1004/// `anonymous` (no auth configured).
1005fn request_owner(state: &AppState, principal: &Option<mongreldb_core::Principal>) -> String {
1006    if let Some(p) = principal {
1007        return p.username.clone();
1008    }
1009    if state.auth_token.is_some() {
1010        return "token".into();
1011    }
1012    "anonymous".into()
1013}
1014
1015fn request_principal(
1016    state: &AppState,
1017    principal: &Option<mongreldb_core::Principal>,
1018) -> Option<mongreldb_core::Principal> {
1019    principal.clone().or_else(|| {
1020        state
1021            .auth_token
1022            .as_ref()
1023            .map(|_| mongreldb_core::Principal {
1024                username: "token".into(),
1025                is_admin: true,
1026                roles: Vec::new(),
1027                permissions: Vec::new(),
1028            })
1029    })
1030}
1031
1032/// `POST /sessions` — open a long-lived session for cross-request interactive
1033/// transactions. Returns `{"session_id": "..."}`; send `X-Session-ID: <token>`
1034/// on subsequent `/sql` requests to route to it. The session is owned by the
1035/// authenticated principal and auto-expires after the idle timeout.
1036async fn create_session(
1037    State(state): State<Arc<AppState>>,
1038    OptionalPrincipal(principal): OptionalPrincipal,
1039) -> Response {
1040    if !state.accepting_sql.load(Ordering::Acquire) {
1041        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
1042    }
1043    let owner = request_owner(&state, &principal);
1044    let session = match MongrelSession::open_with_external_modules_as(
1045        Arc::clone(&state.db),
1046        state.external_modules.iter().cloned(),
1047        request_principal(&state, &principal),
1048    ) {
1049        Ok(session) => session.with_query_registry(Arc::clone(&state.query_registry)),
1050        Err(e) => return (status_for_query_error(&e), e.to_string()).into_response(),
1051    };
1052    match state.sessions.create(session, owner.clone()) {
1053        Some(token) => {
1054            state.audit.record(owner, "session.open", "session created");
1055            Json(json!({ "session_id": token })).into_response()
1056        }
1057        None => (
1058            StatusCode::SERVICE_UNAVAILABLE,
1059            "session limit reached; close an idle session or raise --max-sessions",
1060        )
1061            .into_response(),
1062    }
1063}
1064
1065/// `DELETE /sessions/{id}` — close a session, discarding any open (staged but
1066/// uncommitted) transaction. Only the owning principal may close it.
1067async fn close_session(
1068    State(state): State<Arc<AppState>>,
1069    OptionalPrincipal(principal): OptionalPrincipal,
1070    Path(id): Path<String>,
1071) -> Response {
1072    let owner = request_owner(&state, &principal);
1073    if let Some(entry) = state.sessions.take_for_close(&id, &owner) {
1074        entry
1075            .session
1076            .query_registry()
1077            .cancel_session(&id, CancellationReason::SessionClosed);
1078        let deadline = tokio::time::Instant::now() + state.sql_cancel_grace;
1079        while entry.session.query_registry().active_for_session(&id) > 0
1080            && tokio::time::Instant::now() < deadline
1081        {
1082            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1083        }
1084        state.audit.record(owner, "session.close", "session closed");
1085        StatusCode::OK.into_response()
1086    } else {
1087        (
1088            StatusCode::NOT_FOUND,
1089            "session not found or not owned by caller",
1090        )
1091            .into_response()
1092    }
1093}
1094
1095/// Choose a buffered response serialization: `"arrow"` (IPC file) or JSON.
1096/// Streaming IPC is dispatched before query collection.
1097async fn dispatch_buffered_sql_format(
1098    state: &AppState,
1099    format: Option<&str>,
1100    output: ManagedQueryBatches,
1101    query_id: QueryId,
1102    test_hook: Option<mongreldb_query::SqlTestHook>,
1103) -> Response {
1104    let format = format.unwrap_or("json").to_owned();
1105    let max_rows = state.sql_max_output_rows;
1106    let max_bytes = state.sql_max_output_bytes;
1107    let serialized = tokio::task::spawn_blocking(move || {
1108        let result =
1109            serialize_buffered_output(&format, &output, max_rows, max_bytes, test_hook.as_ref());
1110        (output, result)
1111    })
1112    .await;
1113    let (output, result) = match serialized {
1114        Ok(result) => result,
1115        Err(error) => {
1116            return with_query_id(
1117                (
1118                    StatusCode::INTERNAL_SERVER_ERROR,
1119                    Json(json!({
1120                        "status": "aborted",
1121                        "error": {
1122                            "code": "SERIALIZATION_WORKER_FAILED",
1123                            "message": error.to_string(),
1124                            "query_id": query_id.to_string(),
1125                        }
1126                    })),
1127                )
1128                    .into_response(),
1129                query_id,
1130            )
1131        }
1132    };
1133    match result {
1134        Ok(serialized) => {
1135            state.metrics.add_sql_output_bytes(serialized.bytes.len());
1136            output.complete();
1137            let content_type = if serialized.arrow {
1138                "application/vnd.apache.arrow.file"
1139            } else {
1140                "application/json"
1141            };
1142            with_query_id(
1143                ([(header::CONTENT_TYPE, content_type)], serialized.bytes).into_response(),
1144                query_id,
1145            )
1146        }
1147        Err(BufferedSerializationError::Query(error)) => {
1148            output.fail();
1149            state.metrics.inc_sql_errors();
1150            tracked_query_error_response(state, &error, Some(query_id))
1151        }
1152        Err(BufferedSerializationError::Limit(message)) => {
1153            output.fail();
1154            state.metrics.inc_sql_errors();
1155            with_query_id(
1156                (
1157                    StatusCode::PAYLOAD_TOO_LARGE,
1158                    Json(json!({
1159                        "status": "aborted",
1160                        "error": {
1161                            "code": "RESULT_LIMIT_EXCEEDED",
1162                            "message": message,
1163                            "query_id": query_id.to_string(),
1164                        }
1165                    })),
1166                )
1167                    .into_response(),
1168                query_id,
1169            )
1170        }
1171        Err(BufferedSerializationError::Encoding(message)) => {
1172            output.fail();
1173            state.metrics.inc_sql_errors();
1174            with_query_id(
1175                (
1176                    StatusCode::INTERNAL_SERVER_ERROR,
1177                    Json(json!({
1178                        "status": "aborted",
1179                        "error": {
1180                            "code": "SERIALIZATION_FAILED",
1181                            "message": message,
1182                            "query_id": query_id.to_string(),
1183                        }
1184                    })),
1185                )
1186                    .into_response(),
1187                query_id,
1188            )
1189        }
1190    }
1191}
1192
1193/// Validate a prepared-statement name: bare identifier only
1194/// (`[A-Za-z_][A-Za-z0-9_]*`). Prevents SQL injection via the name, which is
1195/// interpolated into `PREPARE <name> AS ...` / `EXECUTE <name>(...)`.
1196fn validate_stmt_name(name: &str) -> Result<(), String> {
1197    let mut chars = name.chars();
1198    match chars.next() {
1199        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1200        _ => return Err("statement name must start with a letter or underscore".into()),
1201    }
1202    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
1203        return Err("statement name may contain only letters, digits, or underscore".into());
1204    }
1205    Ok(())
1206}
1207
1208/// Render a JSON value as a safe SQL literal for `EXECUTE` parameter binding.
1209/// Values are escaped so a client cannot inject SQL through a parameter.
1210/// Returns `Err` for non-scalar JSON (arrays/objects) so the caller rejects the
1211/// request with 400 rather than silently binding NULL.
1212fn render_sql_literal(v: &serde_json::Value) -> Result<String, String> {
1213    match v {
1214        serde_json::Value::Null => Ok("NULL".into()),
1215        serde_json::Value::Bool(b) => {
1216            if *b {
1217                Ok("TRUE".into())
1218            } else {
1219                Ok("FALSE".into())
1220            }
1221        }
1222        serde_json::Value::Number(n) => Ok(n.to_string()),
1223        // Single-quote, doubling embedded single quotes (SQL-standard escape).
1224        serde_json::Value::String(s) => {
1225            let mut out = String::with_capacity(s.len() + 2);
1226            out.push('\'');
1227            for c in s.chars() {
1228                if c == '\'' {
1229                    out.push_str("''");
1230                } else {
1231                    out.push(c);
1232                }
1233            }
1234            out.push('\'');
1235            Ok(out)
1236        }
1237        // Arrays/objects are not valid scalar params; reject explicitly.
1238        _ => Err("prepared-statement parameters must be scalar (null/bool/number/string)".into()),
1239    }
1240}
1241
1242#[derive(Deserialize)]
1243struct PrepareRequest {
1244    name: String,
1245    sql: String,
1246    #[serde(default)]
1247    query_id: Option<QueryId>,
1248    #[serde(default)]
1249    timeout_ms: Option<u64>,
1250}
1251
1252/// `POST /sessions/{id}/prepare` — parse+plan `sql` once and store it under
1253/// `name` on the session. Subsequent `EXECUTE name(...)` calls (via this
1254/// endpoint or `EXECUTE` SQL) reuse the cached plan, skipping re-planning.
1255async fn prepare_statement(
1256    State(state): State<Arc<AppState>>,
1257    OptionalPrincipal(principal): OptionalPrincipal,
1258    Path(id): Path<String>,
1259    headers: axum::http::HeaderMap,
1260    Json(req): Json<PrepareRequest>,
1261) -> Response {
1262    if !state.accepting_sql.load(Ordering::Acquire) {
1263        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
1264    }
1265    if let Err(msg) = validate_stmt_name(&req.name) {
1266        return (StatusCode::BAD_REQUEST, msg).into_response();
1267    }
1268    if mongreldb_query::contains_boolean_ai_predicate(&req.sql) {
1269        return remote_boolean_ai_error();
1270    }
1271    let owner = request_owner(&state, &principal);
1272    let Some(entry) = state.sessions.get(&id, &owner) else {
1273        return (
1274            StatusCode::NOT_FOUND,
1275            "session not found or not owned by caller",
1276        )
1277            .into_response();
1278    };
1279    let (options, query_id) = match resolve_query_options(
1280        &state,
1281        &headers,
1282        req.query_id,
1283        req.timeout_ms,
1284        owner,
1285        Some(id),
1286    ) {
1287        Ok(options) => options,
1288        Err(response) => return *response,
1289    };
1290    let query = match entry.session.register_query(options) {
1291        Ok(query) => query,
1292        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
1293    };
1294    let registration = RegisteredQueryGuard::new(query);
1295    let _sql_permit = match acquire_sql_permit(&state, &entry.session, registration.query()).await {
1296        Ok(permit) => permit,
1297        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
1298    };
1299    let _guard = tokio::select! {
1300        guard = entry.lock.lock() => guard,
1301        _ = registration.query().control().cancelled() => {
1302            return query_error_response(
1303                &registration.query().checkpoint().unwrap_err(),
1304                Some(query_id),
1305            );
1306        }
1307    };
1308    if entry.is_closed() {
1309        return with_query_id(
1310            (StatusCode::NOT_FOUND, "session no longer available").into_response(),
1311            query_id,
1312        );
1313    }
1314    entry.touch();
1315    let sql = format!("PREPARE {} AS {}", req.name, req.sql);
1316    let query = registration.into_query();
1317    match entry.session.run_with_query(&sql, query).await {
1318        Ok(_) => with_query_id(
1319            Json(json!({ "prepared": req.name })).into_response(),
1320            query_id,
1321        ),
1322        Err(error) => tracked_query_error_response(&state, &error, Some(query_id)),
1323    }
1324}
1325
1326#[derive(Deserialize)]
1327struct ExecuteRequest {
1328    name: String,
1329    params: Vec<serde_json::Value>,
1330    #[serde(default)]
1331    format: Option<String>,
1332    #[serde(default)]
1333    query_id: Option<QueryId>,
1334    #[serde(default)]
1335    timeout_ms: Option<u64>,
1336}
1337
1338/// `POST /sessions/{id}/execute` — run a previously-prepared statement with
1339/// typed parameters, reusing its cached plan. Returns the same formats as
1340/// `/sql` (`json` default, `arrow`, `arrow-stream`).
1341async fn execute_statement(
1342    State(state): State<Arc<AppState>>,
1343    OptionalPrincipal(principal): OptionalPrincipal,
1344    Path(id): Path<String>,
1345    headers: axum::http::HeaderMap,
1346    Json(req): Json<ExecuteRequest>,
1347) -> Response {
1348    if !state.accepting_sql.load(Ordering::Acquire) {
1349        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
1350    }
1351    if let Err(msg) = validate_stmt_name(&req.name) {
1352        return (StatusCode::BAD_REQUEST, msg).into_response();
1353    }
1354    let owner = request_owner(&state, &principal);
1355    let Some(entry) = state.sessions.get(&id, &owner) else {
1356        return (
1357            StatusCode::NOT_FOUND,
1358            "session not found or not owned by caller",
1359        )
1360            .into_response();
1361    };
1362    let (options, query_id) = match resolve_query_options(
1363        &state,
1364        &headers,
1365        req.query_id,
1366        req.timeout_ms,
1367        owner,
1368        Some(id),
1369    ) {
1370        Ok(options) => options,
1371        Err(response) => return *response,
1372    };
1373    let query = match entry.session.register_query(options) {
1374        Ok(query) => query,
1375        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
1376    };
1377    let registration = RegisteredQueryGuard::new(query);
1378    let sql_permit = match acquire_sql_permit(&state, &entry.session, registration.query()).await {
1379        Ok(permit) => permit,
1380        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
1381    };
1382    let _guard = tokio::select! {
1383        guard = entry.lock.lock() => guard,
1384        _ = registration.query().control().cancelled() => {
1385            return query_error_response(
1386                &registration.query().checkpoint().unwrap_err(),
1387                Some(query_id),
1388            );
1389        }
1390    };
1391    if entry.is_closed() {
1392        return with_query_id(
1393            (StatusCode::NOT_FOUND, "session no longer available").into_response(),
1394            query_id,
1395        );
1396    }
1397    entry.touch();
1398    state.metrics.inc_sql_queries();
1399    let literals: Vec<String> = match req
1400        .params
1401        .iter()
1402        .map(render_sql_literal)
1403        .collect::<Result<_, _>>()
1404    {
1405        Ok(v) => v,
1406        Err(msg) => {
1407            state.metrics.inc_sql_errors();
1408            return (StatusCode::BAD_REQUEST, msg).into_response();
1409        }
1410    };
1411    let sql = format!("EXECUTE {}({})", req.name, literals.join(", "));
1412    let start = std::time::Instant::now();
1413    let result = if req.format.as_deref() == Some("arrow-stream") {
1414        let query = registration.into_query();
1415        match entry
1416            .session
1417            .run_stream_with_query_for_serialization(&sql, query)
1418            .await
1419        {
1420            Ok((stream, completion)) => Ok(sql_arrow_stream_response_controlled(
1421                stream,
1422                completion,
1423                sql_permit,
1424                state.sql_max_output_rows,
1425                state.sql_max_output_bytes,
1426                Arc::clone(&state.metrics),
1427            )),
1428            Err(error) => Err(error),
1429        }
1430    } else {
1431        let query = registration.into_query();
1432        match entry
1433            .session
1434            .run_with_query_for_serialization(&sql, query)
1435            .await
1436        {
1437            Ok(output) => Ok(dispatch_buffered_sql_format(
1438                &state,
1439                req.format.as_deref(),
1440                output,
1441                query_id,
1442                entry.session.sql_test_hook(),
1443            )
1444            .await),
1445            Err(error) => Err(error),
1446        }
1447    };
1448    let elapsed = start.elapsed();
1449    if elapsed >= state.slow_query_threshold {
1450        state.metrics.inc_slow_queries();
1451        eprintln!(
1452            "[slow-query] {}\u{00b5}s \u{2014} EXECUTE {}",
1453            elapsed.as_micros(),
1454            req.name
1455        );
1456    }
1457    match result {
1458        Ok(response) => with_query_id(response, query_id),
1459        Err(e) => {
1460            state.metrics.inc_sql_errors();
1461            // A reference to an unprepared/unknown statement is a client error.
1462            let msg = format!("{e}");
1463            let status = if msg.contains("does not exist") {
1464                StatusCode::NOT_FOUND
1465            } else {
1466                status_for_query_error(&e)
1467            };
1468            if status == status_for_query_error(&e) {
1469                tracked_query_error_response(&state, &e, Some(query_id))
1470            } else {
1471                with_query_id(
1472                    (status, format!("{msg} ({}µs)", elapsed.as_micros())).into_response(),
1473                    query_id,
1474                )
1475            }
1476        }
1477    }
1478}
1479
1480/// `DELETE /sessions/{id}/statements/{name}` — drop a prepared statement from
1481/// the session (SQL `DEALLOCATE`).
1482async fn deallocate_statement(
1483    State(state): State<Arc<AppState>>,
1484    OptionalPrincipal(principal): OptionalPrincipal,
1485    Path((id, name)): Path<(String, String)>,
1486) -> Response {
1487    if let Err(msg) = validate_stmt_name(&name) {
1488        return (StatusCode::BAD_REQUEST, msg).into_response();
1489    }
1490    let owner = request_owner(&state, &principal);
1491    let Some(entry) = state.sessions.get(&id, &owner) else {
1492        return (
1493            StatusCode::NOT_FOUND,
1494            "session not found or not owned by caller",
1495        )
1496            .into_response();
1497    };
1498    let _guard = entry.lock.lock().await;
1499    if entry.is_closed() {
1500        return (StatusCode::NOT_FOUND, "session no longer available").into_response();
1501    }
1502    entry.touch();
1503    let sql = format!("DEALLOCATE {name}");
1504    match entry.session.run(&sql).await {
1505        Ok(_) => Json(json!({ "deallocated": name })).into_response(),
1506        Err(e) => (status_for_query_error(&e), e.to_string()).into_response(),
1507    }
1508}
1509
1510/// `mongreldb_tables` gauge. Subject to the same auth middleware as every other
1511/// route (scrape with the configured Bearer token / Basic credentials).
1512async fn metrics_handler(
1513    State(state): State<Arc<AppState>>,
1514    OptionalPrincipal(principal): OptionalPrincipal,
1515) -> Response {
1516    if let Err(error) = state.db.require_for(
1517        request_principal(&state, &principal).as_ref(),
1518        &mongreldb_core::Permission::Admin,
1519    ) {
1520        return (status_for_error(&error), error.to_string()).into_response();
1521    }
1522    let body = state.metrics.prometheus_text(
1523        state.db.table_names().len(),
1524        state.query_registry.active_count(),
1525        state.query_registry.queued_count(),
1526        state.query_registry.entry_count(),
1527        state.query_registry.approximate_bytes(),
1528    );
1529    (
1530        [(
1531            header::CONTENT_TYPE,
1532            "text/plain; version=0.0.4; charset=utf-8".to_string(),
1533        )],
1534        body,
1535    )
1536        .into_response()
1537}
1538
1539/// `POST /compact` — compact all mounted tables.
1540async fn compact_all(
1541    State(state): State<Arc<AppState>>,
1542    OptionalPrincipal(principal): OptionalPrincipal,
1543) -> (StatusCode, Json<serde_json::Value>) {
1544    if let Err(error) = state.db.require_for(
1545        request_principal(&state, &principal).as_ref(),
1546        &mongreldb_core::Permission::Ddl,
1547    ) {
1548        return (
1549            status_for_error(&error),
1550            Json(json!({ "status": "error", "message": error.to_string() })),
1551        );
1552    }
1553    match state.db.compact() {
1554        Ok((compacted, skipped)) => (
1555            StatusCode::OK,
1556            Json(json!({
1557                "status": "ok",
1558                "compacted": compacted,
1559                "skipped": skipped,
1560            })),
1561        ),
1562        Err(e) => (
1563            StatusCode::INTERNAL_SERVER_ERROR,
1564            Json(json!({ "status": "error", "message": format!("{e}") })),
1565        ),
1566    }
1567}
1568
1569/// `POST /tables/{name}/compact` — compact a single table.
1570async fn compact_table(
1571    State(state): State<Arc<AppState>>,
1572    OptionalPrincipal(principal): OptionalPrincipal,
1573    Path(name): Path<String>,
1574) -> (StatusCode, Json<serde_json::Value>) {
1575    if let Err(error) = state.db.require_for(
1576        request_principal(&state, &principal).as_ref(),
1577        &mongreldb_core::Permission::Ddl,
1578    ) {
1579        return (
1580            status_for_error(&error),
1581            Json(json!({ "status": "error", "table": name, "message": error.to_string() })),
1582        );
1583    }
1584    match state.db.compact_table(&name) {
1585        Ok(true) => (
1586            StatusCode::OK,
1587            Json(json!({ "status": "compacted", "table": name })),
1588        ),
1589        Ok(false) => (
1590            StatusCode::OK,
1591            Json(json!({ "status": "skipped", "table": name, "reason": "fewer than 2 runs" })),
1592        ),
1593        Err(e) => (
1594            StatusCode::INTERNAL_SERVER_ERROR,
1595            Json(json!({ "status": "error", "table": name, "message": format!("{e}") })),
1596        ),
1597    }
1598}
1599
1600#[derive(Deserialize)]
1601struct CreateTableRequest {
1602    name: String,
1603    columns: Vec<ColumnDefJson>,
1604}
1605
1606#[derive(Deserialize)]
1607struct ColumnDefJson {
1608    id: u16,
1609    name: String,
1610    ty: String,
1611    primary_key: bool,
1612    #[serde(default)]
1613    nullable: bool,
1614}
1615
1616async fn create_table(
1617    State(state): State<Arc<AppState>>,
1618    OptionalPrincipal(principal): OptionalPrincipal,
1619    Json(req): Json<CreateTableRequest>,
1620) -> Response {
1621    if let Err(error) = state.db.require_for(
1622        request_principal(&state, &principal).as_ref(),
1623        &mongreldb_core::Permission::Ddl,
1624    ) {
1625        return (status_for_error(&error), error.to_string()).into_response();
1626    }
1627    let mut columns = Vec::new();
1628    for c in &req.columns {
1629        let ty = match c.ty.as_str() {
1630            "int64" | "bigint" => TypeId::Int64,
1631            "float64" | "double" => TypeId::Float64,
1632            "bytes" | "varchar" | "text" => TypeId::Bytes,
1633            "bool" => TypeId::Bool,
1634            other => {
1635                return (StatusCode::BAD_REQUEST, format!("unknown type: {other}")).into_response()
1636            }
1637        };
1638        let mut flags = mongreldb_core::schema::ColumnFlags::empty();
1639        if c.primary_key {
1640            flags = flags.with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY);
1641        }
1642        if c.nullable {
1643            flags = flags.with(mongreldb_core::schema::ColumnFlags::NULLABLE);
1644        }
1645        columns.push(mongreldb_core::schema::ColumnDef {
1646            id: c.id,
1647            name: c.name.clone(),
1648            ty,
1649            flags,
1650            default_value: None,
1651        });
1652    }
1653    let schema = Schema {
1654        schema_id: 0,
1655        columns,
1656        indexes: vec![],
1657        colocation: vec![],
1658        constraints: Default::default(),
1659        clustered: false,
1660    };
1661    if let Err(msg) = validate_table_name(&req.name) {
1662        return (StatusCode::BAD_REQUEST, msg).into_response();
1663    }
1664    match state.db.create_table(&req.name, schema) {
1665        Ok(id) => Json(json!({ "table_id": id })).into_response(),
1666        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1667    }
1668}
1669
1670async fn list_tables(
1671    State(state): State<Arc<AppState>>,
1672    OptionalPrincipal(principal): OptionalPrincipal,
1673) -> Json<Vec<String>> {
1674    let principal = request_principal(&state, &principal);
1675    Json(
1676        state
1677            .db
1678            .table_names()
1679            .into_iter()
1680            .filter(|table| {
1681                state
1682                    .db
1683                    .select_column_ids_for(table, principal.as_ref())
1684                    .is_ok()
1685            })
1686            .collect(),
1687    )
1688}
1689
1690async fn drop_table(
1691    State(state): State<Arc<AppState>>,
1692    OptionalPrincipal(principal): OptionalPrincipal,
1693    Path(name): Path<String>,
1694) -> Response {
1695    if let Err(error) = state.db.require_for(
1696        request_principal(&state, &principal).as_ref(),
1697        &mongreldb_core::Permission::Ddl,
1698    ) {
1699        return (status_for_error(&error), error.to_string()).into_response();
1700    }
1701    match state.db.drop_table(&name) {
1702        Ok(_) => {
1703            // Invalidate cached idempotency entries. A cached transaction
1704            // may reference the dropped table; replaying it would silently
1705            // report success without writing to the recreated table.
1706            state.idem.clear();
1707            StatusCode::OK.into_response()
1708        }
1709        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1710    }
1711}
1712
1713#[derive(Deserialize)]
1714struct PutRequest {
1715    row: Vec<serde_json::Value>,
1716}
1717
1718pub(crate) fn json_to_value(v: &serde_json::Value, expected: &TypeId) -> Value {
1719    match (v, expected) {
1720        (serde_json::Value::Number(n), TypeId::Float64) => {
1721            n.as_f64().map(Value::Float64).unwrap_or(Value::Null)
1722        }
1723        (serde_json::Value::Number(n), TypeId::Int64) => {
1724            n.as_i64().map(Value::Int64).unwrap_or(Value::Null)
1725        }
1726        (serde_json::Value::String(s), TypeId::Bytes) => Value::Bytes(s.as_bytes().to_vec()),
1727        (serde_json::Value::String(s), TypeId::Enum { variants }) => {
1728            if variants.iter().any(|v| v == s) {
1729                Value::Bytes(s.as_bytes().to_vec())
1730            } else {
1731                Value::Null
1732            }
1733        }
1734        (serde_json::Value::Bool(b), TypeId::Bool) => Value::Bool(*b),
1735        // Embedding input: a JSON array of numbers, validated against the
1736        // declared dimension. Mismatched length or non-numeric elements → Null.
1737        (serde_json::Value::Array(arr), TypeId::Embedding { dim }) => {
1738            if arr.len() as u32 != *dim {
1739                return Value::Null;
1740            }
1741            let vec: Option<Vec<f32>> =
1742                arr.iter().map(|el| el.as_f64().map(|f| f as f32)).collect();
1743            vec.map(Value::Embedding).unwrap_or(Value::Null)
1744        }
1745        (serde_json::Value::Null, _) => Value::Null,
1746        // Lenient fallbacks for unknown/loosely-typed JSON.
1747        (serde_json::Value::Number(n), _) => {
1748            if let Some(i) = n.as_i64() {
1749                Value::Int64(i)
1750            } else if let Some(f) = n.as_f64() {
1751                Value::Float64(f)
1752            } else {
1753                Value::Null
1754            }
1755        }
1756        (serde_json::Value::String(s), _) => Value::Bytes(s.as_bytes().to_vec()),
1757        (serde_json::Value::Bool(b), _) => Value::Bool(*b),
1758        _ => Value::Null,
1759    }
1760}
1761
1762/// Parse a flat JSON array `[col_id, val, col_id, val, ...]` into typed cell
1763/// pairs, validating the schema. Returns `Err(message)` on any malformed pair.
1764fn parse_cells(
1765    row: &[serde_json::Value],
1766    schema: &mongreldb_core::schema::Schema,
1767) -> Result<Vec<(u16, Value)>, String> {
1768    if row.len() & 1 != 0 {
1769        return Err("row must be an even-length array of [col_id, value] pairs".into());
1770    }
1771    let mut out = Vec::with_capacity(row.len() / 2);
1772    for chunk in row.chunks(2) {
1773        let col_id = chunk[0]
1774            .as_u64()
1775            .ok_or("column id must be a non-negative integer")? as u16;
1776        let expected = schema
1777            .columns
1778            .iter()
1779            .find(|c| c.id == col_id)
1780            .map(|c| c.ty.clone())
1781            .ok_or_else(|| format!("unknown column id {col_id}"))?;
1782        let val = json_to_value(&chunk[1], &expected);
1783        out.push((col_id, val));
1784    }
1785    Ok(out)
1786}
1787
1788/// Basic validation for a table name: non-empty and no path separators.
1789pub(crate) fn validate_table_name(name: &str) -> Result<(), String> {
1790    if name.is_empty() {
1791        return Err("table name must not be empty".into());
1792    }
1793    if name.contains('/') || name.contains('\\') || name.contains('\0') {
1794        return Err("table name contains invalid characters".into());
1795    }
1796    Ok(())
1797}
1798
1799async fn put_row(
1800    State(state): State<Arc<AppState>>,
1801    OptionalPrincipal(principal): OptionalPrincipal,
1802    Path(name): Path<String>,
1803    Json(req): Json<PutRequest>,
1804) -> Response {
1805    let handle = match state.db.table(&name) {
1806        Ok(h) => h,
1807        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
1808    };
1809    let schema = handle.lock().schema().clone();
1810    let row = match parse_cells(&req.row, &schema) {
1811        Ok(r) => r,
1812        Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
1813    };
1814    state.metrics.inc_puts();
1815    let principal = request_principal(&state, &principal);
1816    match state.db.put_for(&name, row, principal.as_ref()) {
1817        Ok(rid) => Json(json!({ "row_id": rid.0.to_string() })).into_response(),
1818        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1819    }
1820}
1821
1822async fn count(
1823    State(state): State<Arc<AppState>>,
1824    OptionalPrincipal(principal): OptionalPrincipal,
1825    Path(name): Path<String>,
1826) -> Response {
1827    let principal = request_principal(&state, &principal);
1828    match state.db.count_for(&name, principal.as_ref()) {
1829        Ok(count) => Json(json!({ "count": count })).into_response(),
1830        Err(error) => (status_for_error(&error), error.to_string()).into_response(),
1831    }
1832}
1833
1834async fn commit(
1835    State(state): State<Arc<AppState>>,
1836    OptionalPrincipal(principal): OptionalPrincipal,
1837    Path(name): Path<String>,
1838) -> Response {
1839    if let Err(error) = state.db.require_for(
1840        request_principal(&state, &principal).as_ref(),
1841        &mongreldb_core::Permission::Update {
1842            table: name.clone(),
1843        },
1844    ) {
1845        return (status_for_error(&error), error.to_string()).into_response();
1846    }
1847    let handle = match state.db.table(&name) {
1848        Ok(h) => h,
1849        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
1850    };
1851    let mut g = handle.lock();
1852    state.metrics.inc_commits();
1853    match g.commit() {
1854        Ok(epoch) => Json(json!({ "epoch": epoch.0 })).into_response(),
1855        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1856    }
1857}
1858
1859#[derive(Deserialize)]
1860struct SqlRequest {
1861    sql: String,
1862    /// Output format: `"json"` (the default) for a JSON array of row objects,
1863    /// `"arrow"` for Arrow IPC file bytes.
1864    #[serde(default)]
1865    format: Option<String>,
1866    /// Body values take precedence over the equivalent convenience headers.
1867    #[serde(default)]
1868    query_id: Option<QueryId>,
1869    #[serde(default)]
1870    timeout_ms: Option<u64>,
1871}
1872
1873fn query_error_response(
1874    error: &mongreldb_query::MongrelQueryError,
1875    query_id: Option<QueryId>,
1876) -> Response {
1877    use mongreldb_query::MongrelQueryError;
1878    let (code, id) = match error {
1879        MongrelQueryError::QueryCancelled { query_id, .. } => ("QUERY_CANCELLED", Some(*query_id)),
1880        MongrelQueryError::DeadlineExceeded { query_id, .. } => {
1881            ("DEADLINE_EXCEEDED", Some(*query_id))
1882        }
1883        MongrelQueryError::QueryIdConflict { query_id } => ("QUERY_ID_CONFLICT", Some(*query_id)),
1884        MongrelQueryError::QueryRegistryFull => ("QUERY_REGISTRY_FULL", query_id),
1885        MongrelQueryError::TransactionAborted => ("TRANSACTION_ABORTED", query_id),
1886        MongrelQueryError::CommitOutcome { query_id, .. } => ("COMMIT_OUTCOME", Some(*query_id)),
1887        _ => ("QUERY_FAILED", query_id),
1888    };
1889    let mut response = (
1890        status_for_query_error(error),
1891        Json(json!({
1892            "status": "aborted",
1893            "error": {
1894                "code": code,
1895                "message": error.to_string(),
1896                "query_id": id.map(|value| value.to_string()),
1897            }
1898        })),
1899    )
1900        .into_response();
1901    if let Some(id) = id {
1902        add_query_id_header(&mut response, id);
1903    }
1904    response
1905}
1906
1907fn record_query_error(metrics: &metrics::Metrics, error: &mongreldb_query::MongrelQueryError) {
1908    match error {
1909        mongreldb_query::MongrelQueryError::QueryCancelled { reason, .. } => {
1910            metrics.inc_sql_cancelled(*reason)
1911        }
1912        mongreldb_query::MongrelQueryError::DeadlineExceeded { .. } => {
1913            metrics.inc_sql_deadline_exceeded();
1914            metrics.inc_sql_cancelled(CancellationReason::Deadline);
1915        }
1916        _ => {}
1917    }
1918}
1919
1920fn tracked_query_error_response(
1921    state: &AppState,
1922    error: &mongreldb_query::MongrelQueryError,
1923    query_id: Option<QueryId>,
1924) -> Response {
1925    record_query_error(&state.metrics, error);
1926    if let mongreldb_query::MongrelQueryError::QueryCancelled { query_id, .. } = error {
1927        if let Some(requested_at) = state
1928            .query_registry
1929            .status(*query_id)
1930            .and_then(|status| status.cancel_requested_at)
1931        {
1932            state
1933                .metrics
1934                .observe_sql_cancel_latency(requested_at.elapsed());
1935        }
1936    }
1937    query_error_response(error, query_id)
1938}
1939
1940fn add_query_id_header(response: &mut Response, query_id: QueryId) {
1941    if let Ok(value) = axum::http::HeaderValue::from_str(&query_id.to_string()) {
1942        response.headers_mut().insert("x-mongreldb-query-id", value);
1943    }
1944}
1945
1946fn with_query_id(mut response: Response, query_id: QueryId) -> Response {
1947    add_query_id_header(&mut response, query_id);
1948    response
1949}
1950
1951fn bad_query_control_request(message: impl Into<String>, query_id: Option<QueryId>) -> Response {
1952    let mut response = (
1953        StatusCode::BAD_REQUEST,
1954        Json(json!({
1955            "status": "aborted",
1956            "error": {
1957                "code": "INVALID_QUERY_OPTIONS",
1958                "message": message.into(),
1959                "query_id": query_id.map(|value| value.to_string()),
1960            }
1961        })),
1962    )
1963        .into_response();
1964    if let Some(query_id) = query_id {
1965        add_query_id_header(&mut response, query_id);
1966    }
1967    response
1968}
1969
1970fn resolve_query_options(
1971    state: &AppState,
1972    headers: &axum::http::HeaderMap,
1973    body_query_id: Option<QueryId>,
1974    body_timeout_ms: Option<u64>,
1975    owner: String,
1976    session_id: Option<String>,
1977) -> std::result::Result<(SqlQueryOptions, QueryId), Box<Response>> {
1978    let query_id = match body_query_id {
1979        Some(query_id) => query_id,
1980        None => match headers.get("x-mongreldb-query-id") {
1981            Some(value) => {
1982                let value = value.to_str().map_err(|_| {
1983                    Box::new(bad_query_control_request(
1984                        "X-MongrelDB-Query-ID is not valid text",
1985                        None,
1986                    ))
1987                })?;
1988                value
1989                    .parse()
1990                    .map_err(|error: mongreldb_query::MongrelQueryError| {
1991                        Box::new(bad_query_control_request(error.to_string(), None))
1992                    })?
1993            }
1994            None => {
1995                QueryId::random().map_err(|error| Box::new(query_error_response(&error, None)))?
1996            }
1997        },
1998    };
1999    let timeout_ms = match body_timeout_ms {
2000        Some(timeout_ms) => timeout_ms,
2001        None => match headers.get("x-mongreldb-timeout-ms") {
2002            Some(value) => value
2003                .to_str()
2004                .ok()
2005                .and_then(|value| value.parse::<u64>().ok())
2006                .ok_or_else(|| {
2007                    Box::new(bad_query_control_request(
2008                        "X-MongrelDB-Timeout-Ms must be a positive integer",
2009                        Some(query_id),
2010                    ))
2011                })?,
2012            None => state
2013                .sql_default_timeout
2014                .as_millis()
2015                .min(u128::from(u64::MAX)) as u64,
2016        },
2017    };
2018    if timeout_ms == 0 {
2019        return Err(Box::new(bad_query_control_request(
2020            "timeout_ms must be positive",
2021            Some(query_id),
2022        )));
2023    }
2024    let timeout = std::time::Duration::from_millis(timeout_ms);
2025    if timeout > state.sql_max_timeout {
2026        return Err(Box::new(bad_query_control_request(
2027            format!(
2028                "timeout_ms exceeds server maximum of {}",
2029                state.sql_max_timeout.as_millis()
2030            ),
2031            Some(query_id),
2032        )));
2033    }
2034    Ok((
2035        SqlQueryOptions {
2036            query_id: Some(query_id),
2037            timeout: Some(timeout),
2038            owner: Some(owner),
2039            session_id,
2040            parent_control: None,
2041        },
2042        query_id,
2043    ))
2044}
2045
2046async fn acquire_sql_permit(
2047    state: &AppState,
2048    session: &MongrelSession,
2049    query: &RegisteredSqlQuery,
2050) -> std::result::Result<tokio::sync::OwnedSemaphorePermit, mongreldb_query::MongrelQueryError> {
2051    session.fire_test_hook(mongreldb_query::SqlTestHookPoint::WaitingForSqlPermit);
2052    tokio::select! {
2053        permit = Arc::clone(&state.sql_semaphore).acquire_owned() => permit.map_err(|_| {
2054            mongreldb_query::MongrelQueryError::InvalidQueryState(
2055                "SQL admission semaphore closed".into(),
2056            )
2057        }),
2058        _ = query.control().cancelled() => Err(query.checkpoint().unwrap_err()),
2059    }
2060}
2061
2062fn caller_may_manage_query(
2063    state: &AppState,
2064    principal: &Option<mongreldb_core::Principal>,
2065    owner: Option<&str>,
2066) -> bool {
2067    request_principal(state, principal).is_some_and(|principal| principal.is_admin)
2068        || owner == Some(request_owner(state, principal).as_str())
2069}
2070
2071fn query_phase_name(phase: SqlQueryPhase) -> &'static str {
2072    match phase {
2073        SqlQueryPhase::Queued => "queued",
2074        SqlQueryPhase::Planning => "planning",
2075        SqlQueryPhase::Executing => "executing",
2076        SqlQueryPhase::Streaming => "streaming",
2077        SqlQueryPhase::Serializing => "serializing",
2078        SqlQueryPhase::CommitCritical => "commit_critical",
2079        SqlQueryPhase::Cancelling => "cancelling",
2080        SqlQueryPhase::Completed => "completed",
2081        SqlQueryPhase::Failed => "failed",
2082        SqlQueryPhase::Cancelled => "cancelled",
2083    }
2084}
2085
2086fn commit_fence_outcome_name(outcome: mongreldb_query::CommitFenceOutcome) -> &'static str {
2087    match outcome {
2088        mongreldb_query::CommitFenceOutcome::NotReached => "not_reached",
2089        mongreldb_query::CommitFenceOutcome::CancelWon => "cancel_won",
2090        mongreldb_query::CommitFenceOutcome::CommitWon => "commit_won",
2091    }
2092}
2093
2094async fn query_status(
2095    State(state): State<Arc<AppState>>,
2096    OptionalPrincipal(principal): OptionalPrincipal,
2097    Path(query_id): Path<String>,
2098) -> Response {
2099    let Ok(query_id) = query_id.parse::<QueryId>() else {
2100        return StatusCode::NOT_FOUND.into_response();
2101    };
2102    let Some(status) = state.query_registry.status(query_id) else {
2103        return StatusCode::NOT_FOUND.into_response();
2104    };
2105    if !caller_may_manage_query(&state, &principal, status.owner.as_deref()) {
2106        return StatusCode::NOT_FOUND.into_response();
2107    }
2108    let response = Json(json!({
2109        "query_id": query_id.to_string(),
2110        "state": query_phase_name(status.phase),
2111        "started_ms_ago": status.started_at.elapsed().as_millis(),
2112        "deadline_ms_remaining": status.deadline.map(|deadline| {
2113            deadline.saturating_duration_since(std::time::Instant::now()).as_millis()
2114        }),
2115        "session_id": status.session_id,
2116        "operation": status.operation,
2117        "committed": status.committed,
2118        "cancellation_reason": format!("{:?}", status.cancellation_reason).to_ascii_lowercase(),
2119        "completed_statements": status.completed_statements,
2120        "statement_index": status.statement_index,
2121        "trace": {
2122            "queue_duration_us": status.queue_duration.as_micros(),
2123            "planning_duration_us": status.planning_duration.as_micros(),
2124            "execution_duration_us": status.execution_duration.as_micros(),
2125            "serialization_duration_us": status.serialization_duration.as_micros(),
2126            "cancel_requested_phase": status.cancel_requested_phase.map(query_phase_name),
2127            "cancel_observed_phase": status.cancel_observed_phase.map(query_phase_name),
2128            "commit_fence_outcome": commit_fence_outcome_name(status.commit_fence_outcome),
2129        },
2130    }))
2131    .into_response();
2132    with_query_id(response, query_id)
2133}
2134
2135async fn cancel_query(
2136    State(state): State<Arc<AppState>>,
2137    OptionalPrincipal(principal): OptionalPrincipal,
2138    Path(query_id): Path<String>,
2139) -> Response {
2140    let Ok(query_id) = query_id.parse::<QueryId>() else {
2141        return StatusCode::NOT_FOUND.into_response();
2142    };
2143    let Some(status) = state.query_registry.status(query_id) else {
2144        return StatusCode::NOT_FOUND.into_response();
2145    };
2146    if !caller_may_manage_query(&state, &principal, status.owner.as_deref()) {
2147        return StatusCode::NOT_FOUND.into_response();
2148    }
2149    state.metrics.inc_sql_cancel_requests();
2150    let (http_status, body) = match state.query_registry.cancel(query_id) {
2151        CancelOutcome::Accepted => (
2152            {
2153                state.metrics.inc_sql_commit_cancel_winner_cancel();
2154                StatusCode::ACCEPTED
2155            },
2156            json!({ "query_id": query_id.to_string(), "state": "cancellation_requested" }),
2157        ),
2158        CancelOutcome::AlreadyCancelling => (
2159            StatusCode::OK,
2160            json!({ "query_id": query_id.to_string(), "state": "cancelling" }),
2161        ),
2162        CancelOutcome::TooLate => (
2163            {
2164                state.metrics.inc_sql_commit_cancel_winner_commit();
2165                StatusCode::CONFLICT
2166            },
2167            json!({
2168                "query_id": query_id.to_string(),
2169                "state": "commit_critical",
2170                "error": {
2171                    "code": "CANCEL_TOO_LATE",
2172                    "message": "the query has entered its durable commit phase"
2173                }
2174            }),
2175        ),
2176        CancelOutcome::AlreadyFinished => (
2177            StatusCode::OK,
2178            json!({ "query_id": query_id.to_string(), "state": "finished" }),
2179        ),
2180        CancelOutcome::NotFound => return StatusCode::NOT_FOUND.into_response(),
2181    };
2182    with_query_id((http_status, Json(body)).into_response(), query_id)
2183}
2184
2185async fn sql(
2186    State(state): State<Arc<AppState>>,
2187    OptionalPrincipal(principal): OptionalPrincipal,
2188    headers: axum::http::HeaderMap,
2189    Json(req): Json<SqlRequest>,
2190) -> Response {
2191    if !state.accepting_sql.load(Ordering::Acquire) {
2192        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
2193    }
2194    if mongreldb_query::contains_boolean_ai_predicate(&req.sql) {
2195        return remote_boolean_ai_error();
2196    }
2197    // Session routing: an `X-Session-ID` header routes the request to a pooled
2198    // long-lived session, enabling cross-request `BEGIN`/`INSERT`/`COMMIT`
2199    // transactions. Without the header, a fresh ephemeral session is used
2200    // (the historical behavior).
2201    let session_id = headers
2202        .get("x-session-id")
2203        .and_then(|v| v.to_str().ok())
2204        .map(str::to_owned);
2205
2206    let owner = request_owner(&state, &principal);
2207    if let Some(sid) = session_id {
2208        let Some(entry) = state.sessions.get(&sid, &owner) else {
2209            return (
2210                StatusCode::NOT_FOUND,
2211                "session not found or not owned by caller",
2212            )
2213                .into_response();
2214        };
2215        let (options, query_id) = match resolve_query_options(
2216            &state,
2217            &headers,
2218            req.query_id,
2219            req.timeout_ms,
2220            owner,
2221            Some(sid),
2222        ) {
2223            Ok(options) => options,
2224            Err(response) => return *response,
2225        };
2226        let query = match entry.session.register_query(options) {
2227            Ok(query) => query,
2228            Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2229        };
2230        let registration = RegisteredQueryGuard::new(query);
2231        let sql_permit =
2232            match acquire_sql_permit(&state, &entry.session, registration.query()).await {
2233                Ok(permit) => permit,
2234                Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2235            };
2236        // Registration and global admission happen before this session lock.
2237        let _guard = tokio::select! {
2238            guard = entry.lock.lock() => guard,
2239            _ = registration.query().control().cancelled() => {
2240                return query_error_response(
2241                    &registration.query().checkpoint().unwrap_err(),
2242                    Some(query_id),
2243                );
2244            }
2245        };
2246        // Re-check closed: the session may have been closed/evicted between
2247        // get() and acquiring the lock.
2248        if entry.is_closed() {
2249            return (StatusCode::NOT_FOUND, "session no longer available").into_response();
2250        }
2251        entry.touch();
2252        let query = registration.into_query();
2253        execute_sql(
2254            &state,
2255            &principal,
2256            &entry.session,
2257            req,
2258            query,
2259            query_id,
2260            sql_permit,
2261        )
2262        .await
2263    } else {
2264        let session = match MongrelSession::open_with_external_modules_as(
2265            Arc::clone(&state.db),
2266            state.external_modules.iter().cloned(),
2267            request_principal(&state, &principal),
2268        ) {
2269            Ok(session) => session.with_query_registry(Arc::clone(&state.query_registry)),
2270            Err(e) => return (status_for_query_error(&e), e.to_string()).into_response(),
2271        };
2272        let (options, query_id) = match resolve_query_options(
2273            &state,
2274            &headers,
2275            req.query_id,
2276            req.timeout_ms,
2277            owner,
2278            None,
2279        ) {
2280            Ok(options) => options,
2281            Err(response) => return *response,
2282        };
2283        let query = match session.register_query(options) {
2284            Ok(query) => query,
2285            Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2286        };
2287        let registration = RegisteredQueryGuard::new(query);
2288        let sql_permit = match acquire_sql_permit(&state, &session, registration.query()).await {
2289            Ok(permit) => permit,
2290            Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2291        };
2292        let query = registration.into_query();
2293        execute_sql(
2294            &state, &principal, &session, req, query, query_id, sql_permit,
2295        )
2296        .await
2297    }
2298}
2299
2300/// Run one SQL request against a given session: bump counters, audit DDL
2301/// (after execution, with redaction), log slow queries on both success and
2302/// failure, and dispatch the response format. Shared by the pooled-session and
2303/// fresh-session paths.
2304async fn execute_sql(
2305    state: &AppState,
2306    principal: &Option<mongreldb_core::Principal>,
2307    session: &MongrelSession,
2308    req: SqlRequest,
2309    query: RegisteredSqlQuery,
2310    query_id: QueryId,
2311    sql_permit: tokio::sync::OwnedSemaphorePermit,
2312) -> Response {
2313    state.metrics.inc_sql_queries();
2314    let audited = audit::is_audited_sql(&req.sql);
2315    let actor = request_owner(state, principal);
2316    let start = std::time::Instant::now();
2317    // NOTE: deliberately NOT using `run_sql_traced` here. Its thread-local
2318    // push/pop spans an `.await`, and on a multi-threaded tokio runtime the
2319    // task can resume on a different thread, corrupting the trace stack and
2320    // leaking scopes. Wall-clock timing is sufficient for slow-query detection
2321    // and works across awaits.
2322    let result = if req.format.as_deref() == Some("arrow-stream") {
2323        match session
2324            .run_stream_with_query_for_serialization(&req.sql, query)
2325            .await
2326        {
2327            Ok((stream, completion)) => Ok(sql_arrow_stream_response_controlled(
2328                stream,
2329                completion,
2330                sql_permit,
2331                state.sql_max_output_rows,
2332                state.sql_max_output_bytes,
2333                Arc::clone(&state.metrics),
2334            )),
2335            Err(error) => Err(error),
2336        }
2337    } else {
2338        match session
2339            .run_with_query_for_serialization(&req.sql, query)
2340            .await
2341        {
2342            Ok(output) => Ok(dispatch_buffered_sql_format(
2343                state,
2344                req.format.as_deref(),
2345                output,
2346                query_id,
2347                session.sql_test_hook(),
2348            )
2349            .await),
2350            Err(error) => Err(error),
2351        }
2352    };
2353    let elapsed = start.elapsed();
2354    // Slow-query logging covers BOTH success and failure (the slowest errors
2355    // matter most for diagnosis), checked before branching on the outcome.
2356    if elapsed >= state.slow_query_threshold {
2357        state.metrics.inc_slow_queries();
2358        eprintln!(
2359            "[slow-query] {}\u{00b5}s query_id={} operation={}",
2360            elapsed.as_micros(),
2361            query_id,
2362            safe_sql_operation(&req.sql)
2363        );
2364    }
2365    // Audit DDL/privilege AFTER execution so the outcome (ok/fail) is captured.
2366    // `redacted_ddl_detail` never logs credential literals.
2367    if audited {
2368        let (action, detail) = audit::redacted_ddl_detail(&req.sql, result.is_ok());
2369        state.audit.record(actor, action, detail);
2370    }
2371    match result {
2372        Ok(response) => with_query_id(response, query_id),
2373        Err(e) => {
2374            state.metrics.inc_sql_errors();
2375            tracked_query_error_response(state, &e, Some(query_id))
2376        }
2377    }
2378}
2379
2380fn safe_sql_operation(sql: &str) -> String {
2381    sql.split_whitespace()
2382        .next()
2383        .unwrap_or("UNKNOWN")
2384        .chars()
2385        .filter(|character| character.is_ascii_alphabetic())
2386        .take(16)
2387        .collect::<String>()
2388        .to_ascii_uppercase()
2389}
2390
2391fn remote_boolean_ai_error() -> Response {
2392    (
2393        StatusCode::BAD_REQUEST,
2394        "Boolean ANN/Sparse SQL is disabled remotely; use scored SQL functions",
2395    )
2396        .into_response()
2397}
2398
2399#[derive(Debug)]
2400struct SerializedOutput {
2401    bytes: Vec<u8>,
2402    arrow: bool,
2403}
2404
2405#[derive(Debug)]
2406enum BufferedSerializationError {
2407    Query(mongreldb_query::MongrelQueryError),
2408    Limit(String),
2409    Encoding(String),
2410}
2411
2412struct LimitedOutput {
2413    bytes: Vec<u8>,
2414    max_bytes: usize,
2415    exceeded: bool,
2416}
2417
2418impl LimitedOutput {
2419    fn new(max_bytes: usize) -> Self {
2420        Self {
2421            bytes: Vec::new(),
2422            max_bytes,
2423            exceeded: false,
2424        }
2425    }
2426}
2427
2428impl std::io::Write for LimitedOutput {
2429    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2430        if self.bytes.len().saturating_add(bytes.len()) > self.max_bytes {
2431            self.exceeded = true;
2432            return Err(std::io::Error::other("SQL output byte limit exceeded"));
2433        }
2434        self.bytes.extend_from_slice(bytes);
2435        Ok(bytes.len())
2436    }
2437
2438    fn flush(&mut self) -> std::io::Result<()> {
2439        Ok(())
2440    }
2441}
2442
2443fn serialize_buffered_output(
2444    format: &str,
2445    output: &ManagedQueryBatches,
2446    max_rows: usize,
2447    max_bytes: usize,
2448    test_hook: Option<&mongreldb_query::SqlTestHook>,
2449) -> std::result::Result<SerializedOutput, BufferedSerializationError> {
2450    const ROW_CHECKPOINT_INTERVAL: usize = 256;
2451    let batches = output.batches();
2452    let mut rows = 0usize;
2453    let mut writer_output = LimitedOutput::new(max_bytes);
2454
2455    if format == "arrow" {
2456        if batches.is_empty() {
2457            return Ok(SerializedOutput {
2458                bytes: Vec::new(),
2459                arrow: true,
2460            });
2461        }
2462        let schema = batches[0].schema();
2463        let encoding_result = (|| {
2464            let mut writer =
2465                arrow::ipc::writer::FileWriter::try_new(&mut writer_output, schema.as_ref())
2466                    .map_err(|error| error.to_string())?;
2467            for batch in batches {
2468                for offset in (0..batch.num_rows()).step_by(ROW_CHECKPOINT_INTERVAL) {
2469                    if let Some(hook) = test_hook {
2470                        hook(mongreldb_query::SqlTestHookPoint::BeforeSerializationBatch);
2471                    }
2472                    output
2473                        .query()
2474                        .checkpoint()
2475                        .map_err(|error| error.to_string())?;
2476                    let length = ROW_CHECKPOINT_INTERVAL.min(batch.num_rows() - offset);
2477                    rows = rows.saturating_add(length);
2478                    if rows > max_rows {
2479                        return Err("SQL output row limit exceeded".into());
2480                    }
2481                    writer
2482                        .write(&batch.slice(offset, length))
2483                        .map_err(|error| error.to_string())?;
2484                }
2485            }
2486            writer.finish().map_err(|error| error.to_string())
2487        })();
2488        if let Err(error) = encoding_result {
2489            if let Err(query_error) = output.query().checkpoint() {
2490                return Err(BufferedSerializationError::Query(query_error));
2491            }
2492            if writer_output.exceeded || rows > max_rows {
2493                return Err(BufferedSerializationError::Limit(error));
2494            }
2495            return Err(BufferedSerializationError::Encoding(error));
2496        }
2497        return Ok(SerializedOutput {
2498            bytes: writer_output.bytes,
2499            arrow: true,
2500        });
2501    }
2502
2503    let encoding_result = (|| {
2504        let mut writer = arrow::json::writer::ArrayWriter::new(&mut writer_output);
2505        for batch in batches {
2506            for offset in (0..batch.num_rows()).step_by(ROW_CHECKPOINT_INTERVAL) {
2507                if let Some(hook) = test_hook {
2508                    hook(mongreldb_query::SqlTestHookPoint::BeforeSerializationBatch);
2509                }
2510                output
2511                    .query()
2512                    .checkpoint()
2513                    .map_err(|error| error.to_string())?;
2514                let length = ROW_CHECKPOINT_INTERVAL.min(batch.num_rows() - offset);
2515                rows = rows.saturating_add(length);
2516                if rows > max_rows {
2517                    return Err("SQL output row limit exceeded".into());
2518                }
2519                let slice = batch.slice(offset, length);
2520                writer
2521                    .write_batches(&[&slice])
2522                    .map_err(|error| error.to_string())?;
2523            }
2524        }
2525        writer.finish().map_err(|error| error.to_string())
2526    })();
2527    if let Err(error) = encoding_result {
2528        if let Err(query_error) = output.query().checkpoint() {
2529            return Err(BufferedSerializationError::Query(query_error));
2530        }
2531        if writer_output.exceeded || rows > max_rows {
2532            return Err(BufferedSerializationError::Limit(error));
2533        }
2534        return Err(BufferedSerializationError::Encoding(error));
2535    }
2536    Ok(SerializedOutput {
2537        bytes: writer_output.bytes,
2538        arrow: false,
2539    })
2540}
2541
2542/// Serialize a DataFusion record-batch stream as Arrow streaming IPC. The body
2543/// holds only the active query batch and one serialized IPC message.
2544#[cfg(test)]
2545fn sql_arrow_stream_response(batches: mongreldb_query::MongrelRecordBatchStream) -> Response {
2546    use futures::{stream, StreamExt};
2547
2548    const STREAM_CT: &str = "application/vnd.apache.arrow.stream";
2549
2550    let schema = batches.schema();
2551    let mut writer = match arrow::ipc::writer::StreamWriter::try_new(Vec::new(), schema.as_ref()) {
2552        Ok(w) => w,
2553        Err(e) => {
2554            return (
2555                StatusCode::INTERNAL_SERVER_ERROR,
2556                format!("arrow stream init error: {e}"),
2557            )
2558                .into_response()
2559        }
2560    };
2561    // `try_new` synchronously writes the schema message; drain it so it becomes
2562    // the first chunk yielded to the client.
2563    let schema_chunk: Vec<u8> = std::mem::take(writer.get_mut());
2564    let batch_stream = stream::unfold(
2565        (batches, Some(writer)),
2566        |(mut batches, writer)| async move {
2567            let mut writer = writer?;
2568            match batches.next().await {
2569                Some(Ok(batch)) => match writer.write(&batch) {
2570                    Ok(()) => {
2571                        let chunk = std::mem::take(writer.get_mut());
2572                        Some((Ok(chunk), (batches, Some(writer))))
2573                    }
2574                    Err(error) => Some((Err(std::io::Error::other(error)), (batches, None))),
2575                },
2576                Some(Err(error)) => Some((Err(std::io::Error::other(error)), (batches, None))),
2577                None => match writer.finish() {
2578                    Ok(()) => {
2579                        let chunk = std::mem::take(writer.get_mut());
2580                        Some((Ok(chunk), (batches, None)))
2581                    }
2582                    Err(error) => Some((Err(std::io::Error::other(error)), (batches, None))),
2583                },
2584            }
2585        },
2586    );
2587
2588    // Schema first, then batches + EOS. Each item is already `Result<Vec<u8>,
2589    // io::Error>` so a mid-stream encode failure surfaces as a body error.
2590    let schema_item: Result<Vec<u8>, std::io::Error> = Ok(schema_chunk);
2591    let full = stream::iter([schema_item]).chain(batch_stream);
2592    let body = axum::body::Body::from_stream(full);
2593    ([(header::CONTENT_TYPE, STREAM_CT)], body).into_response()
2594}
2595
2596fn sql_arrow_stream_response_controlled(
2597    batches: mongreldb_query::MongrelRecordBatchStream,
2598    completion: SqlStreamCompletion,
2599    sql_permit: tokio::sync::OwnedSemaphorePermit,
2600    max_rows: usize,
2601    max_bytes: usize,
2602    metrics: Arc<metrics::Metrics>,
2603) -> Response {
2604    use futures::{stream, StreamExt};
2605
2606    const STREAM_CT: &str = "application/vnd.apache.arrow.stream";
2607    let schema = batches.schema();
2608    let mut writer = match arrow::ipc::writer::StreamWriter::try_new(Vec::new(), schema.as_ref()) {
2609        Ok(writer) => writer,
2610        Err(error) => {
2611            return (
2612                StatusCode::INTERNAL_SERVER_ERROR,
2613                format!("arrow stream init error: {error}"),
2614            )
2615                .into_response()
2616        }
2617    };
2618    let schema_chunk = std::mem::take(writer.get_mut());
2619    if schema_chunk.len() > max_bytes {
2620        return (
2621            StatusCode::PAYLOAD_TOO_LARGE,
2622            "SQL output byte limit exceeded",
2623        )
2624            .into_response();
2625    }
2626    metrics.add_sql_output_bytes(schema_chunk.len());
2627    let batch_stream = stream::unfold(
2628        (
2629            batches,
2630            Some(writer),
2631            completion,
2632            Some(sql_permit),
2633            0usize,
2634            schema_chunk.len(),
2635            metrics,
2636        ),
2637        move |(mut batches, writer, completion, permit, rows, bytes, metrics)| async move {
2638            let mut writer = writer?;
2639            match batches.next().await {
2640                Some(Ok(batch)) => {
2641                    let next_rows = rows.saturating_add(batch.num_rows());
2642                    if next_rows > max_rows {
2643                        return Some((
2644                            Err(std::io::Error::other("SQL output row limit exceeded")),
2645                            (batches, None, completion, permit, next_rows, bytes, metrics),
2646                        ));
2647                    }
2648                    match writer.write(&batch) {
2649                        Ok(()) => {
2650                            let chunk = std::mem::take(writer.get_mut());
2651                            let next_bytes = bytes.saturating_add(chunk.len());
2652                            if next_bytes > max_bytes {
2653                                return Some((
2654                                    Err(std::io::Error::other("SQL output byte limit exceeded")),
2655                                    (
2656                                        batches, None, completion, permit, next_rows, next_bytes,
2657                                        metrics,
2658                                    ),
2659                                ));
2660                            }
2661                            metrics.add_sql_output_bytes(chunk.len());
2662                            Some((
2663                                Ok(chunk),
2664                                (
2665                                    batches,
2666                                    Some(writer),
2667                                    completion,
2668                                    permit,
2669                                    next_rows,
2670                                    next_bytes,
2671                                    metrics,
2672                                ),
2673                            ))
2674                        }
2675                        Err(error) => Some((
2676                            Err(std::io::Error::other(error)),
2677                            (batches, None, completion, permit, rows, bytes, metrics),
2678                        )),
2679                    }
2680                }
2681                Some(Err(error)) => Some((
2682                    Err(std::io::Error::other(error)),
2683                    (batches, None, completion, permit, rows, bytes, metrics),
2684                )),
2685                None => match writer.finish() {
2686                    Ok(()) => {
2687                        let chunk = std::mem::take(writer.get_mut());
2688                        let next_bytes = bytes.saturating_add(chunk.len());
2689                        if next_bytes > max_bytes {
2690                            return Some((
2691                                Err(std::io::Error::other("SQL output byte limit exceeded")),
2692                                (batches, None, completion, permit, rows, next_bytes, metrics),
2693                            ));
2694                        }
2695                        metrics.add_sql_output_bytes(chunk.len());
2696                        completion.complete();
2697                        Some((
2698                            Ok(chunk),
2699                            (batches, None, completion, permit, rows, next_bytes, metrics),
2700                        ))
2701                    }
2702                    Err(error) => Some((
2703                        Err(std::io::Error::other(error)),
2704                        (batches, None, completion, permit, rows, bytes, metrics),
2705                    )),
2706                },
2707            }
2708        },
2709    );
2710    let schema_item: Result<Vec<u8>, std::io::Error> = Ok(schema_chunk);
2711    let body = axum::body::Body::from_stream(stream::iter([schema_item]).chain(batch_stream));
2712    ([(header::CONTENT_TYPE, STREAM_CT)], body).into_response()
2713}
2714
2715#[derive(Deserialize)]
2716struct TxnOp {
2717    table: String,
2718    op: String,
2719    cells: Option<Vec<serde_json::Value>>,
2720    row_id: Option<u64>,
2721}
2722
2723#[derive(Deserialize)]
2724struct TxnRequest {
2725    ops: Vec<TxnOp>,
2726}
2727
2728async fn txn(
2729    State(state): State<Arc<AppState>>,
2730    OptionalPrincipal(principal): OptionalPrincipal,
2731    Json(req): Json<TxnRequest>,
2732) -> Response {
2733    // Pre-validate every op against the live schemas before entering the
2734    // transaction, so malformed input returns 400 without consuming an epoch
2735    // or poisoning a txn.
2736    let mut parsed: Vec<(String, TxnAction)> = Vec::with_capacity(req.ops.len());
2737    for op in &req.ops {
2738        match op.op.as_str() {
2739            "put" => {
2740                let cells_json = match op.cells.as_ref() {
2741                    Some(c) if !c.is_empty() => c,
2742                    _ => {
2743                        return (StatusCode::BAD_REQUEST, "put op requires non-empty cells")
2744                            .into_response()
2745                    }
2746                };
2747                let handle = match state.db.table(&op.table) {
2748                    Ok(h) => h,
2749                    Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
2750                };
2751                let schema = handle.lock().schema().clone();
2752                let cells = match parse_cells(cells_json, &schema) {
2753                    Ok(c) => c,
2754                    Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
2755                };
2756                parsed.push((op.table.clone(), TxnAction::Put(cells)));
2757            }
2758            "delete" => {
2759                let rid = match op.row_id {
2760                    Some(r) => r,
2761                    None => {
2762                        return (StatusCode::BAD_REQUEST, "delete op requires row_id")
2763                            .into_response()
2764                    }
2765                };
2766                parsed.push((op.table.clone(), TxnAction::Delete(rid)));
2767            }
2768            other => {
2769                return (StatusCode::BAD_REQUEST, format!("unknown op: {other}")).into_response()
2770            }
2771        }
2772    }
2773
2774    state.metrics.inc_txns();
2775    let mut transaction = state.db.begin_as(request_principal(&state, &principal));
2776    let result = (|| {
2777        for (table, action) in &parsed {
2778            match action {
2779                TxnAction::Put(cells) => {
2780                    transaction.put(table, cells.clone())?;
2781                }
2782                TxnAction::Delete(rid) => {
2783                    transaction.delete(table, mongreldb_core::RowId(*rid))?;
2784                }
2785            }
2786        }
2787        transaction.commit()
2788    })();
2789    match result {
2790        Ok(_) => Json(json!({ "status": "committed" })).into_response(),
2791        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
2792    }
2793}
2794
2795enum TxnAction {
2796    Put(Vec<(u16, Value)>),
2797    Delete(u64),
2798}
2799
2800#[cfg(test)]
2801mod auth_tests {
2802    use super::*;
2803    use mongreldb_core::Database;
2804    use tempfile::tempdir;
2805
2806    #[test]
2807    fn slow_query_operation_does_not_include_literals() {
2808        let sql = "CREATE USER alice PASSWORD 'never-log-this'";
2809        let operation = safe_sql_operation(sql);
2810        assert_eq!(operation, "CREATE");
2811        assert!(!operation.contains("never-log-this"));
2812    }
2813
2814    #[tokio::test]
2815    async fn auth_rejects_missing_token() {
2816        let dir = tempdir().unwrap();
2817        let db = Arc::new(Database::create(dir.path()).unwrap());
2818        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
2819        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2820        let addr = listener.local_addr().unwrap();
2821        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2822        // Give the server a moment to start.
2823        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2824
2825        let client = reqwest::Client::new();
2826        let resp = client
2827            .get(format!("http://{addr}/health"))
2828            .send()
2829            .await
2830            .unwrap();
2831        assert_eq!(resp.status(), 401);
2832    }
2833
2834    #[tokio::test]
2835    async fn auth_accepts_valid_token() {
2836        let dir = tempdir().unwrap();
2837        let db = Arc::new(Database::create(dir.path()).unwrap());
2838        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
2839        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2840        let addr = listener.local_addr().unwrap();
2841        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2842        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2843
2844        let client = reqwest::Client::new();
2845        let resp = client
2846            .get(format!("http://{addr}/health"))
2847            .header("Authorization", "Bearer secret")
2848            .send()
2849            .await
2850            .unwrap();
2851        assert_eq!(resp.status(), 200);
2852    }
2853
2854    #[tokio::test]
2855    async fn no_auth_when_token_unset() {
2856        let dir = tempdir().unwrap();
2857        let db = Arc::new(Database::create(dir.path()).unwrap());
2858        let app = build_app_with_config(db, std::iter::empty(), None, None);
2859        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2860        let addr = listener.local_addr().unwrap();
2861        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2862        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2863
2864        let client = reqwest::Client::new();
2865        let resp = client
2866            .get(format!("http://{addr}/health"))
2867            .send()
2868            .await
2869            .unwrap();
2870        assert_eq!(resp.status(), 200);
2871    }
2872
2873    #[tokio::test]
2874    async fn capabilities_advertise_sql_cancellation_v1() {
2875        let dir = tempdir().unwrap();
2876        let db = Arc::new(Database::create(dir.path()).unwrap());
2877        let app = build_app_with_config(db, std::iter::empty(), None, None);
2878        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2879        let addr = listener.local_addr().unwrap();
2880        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2881
2882        let body: serde_json::Value = reqwest::Client::new()
2883            .get(format!("http://{addr}/capabilities"))
2884            .send()
2885            .await
2886            .unwrap()
2887            .json()
2888            .await
2889            .unwrap();
2890        assert_eq!(body["sql_cancellation"]["version"], 1);
2891        assert_eq!(body["sql_cancellation"]["client_query_ids"], true);
2892        assert_eq!(body["sql_cancellation"]["cancel_endpoint"], true);
2893        assert_eq!(body["sql_cancellation"]["query_status"], true);
2894        assert_eq!(body["sql_cancellation"]["stream_disconnect_cancels"], true);
2895    }
2896}
2897
2898#[cfg(test)]
2899mod wal_stream_tests {
2900    use super::*;
2901    use mongreldb_client::ReplicationFollower;
2902    use mongreldb_core::Database;
2903    use tempfile::tempdir;
2904
2905    #[tokio::test]
2906    async fn wal_stream_returns_records_after_commit() {
2907        let dir = tempdir().unwrap();
2908        let db = Arc::new(Database::create(dir.path()).unwrap());
2909        let table_schema = mongreldb_core::schema::Schema {
2910            schema_id: 1,
2911            columns: vec![mongreldb_core::schema::ColumnDef {
2912                id: 1,
2913                name: "id".into(),
2914                ty: TypeId::Int64,
2915                flags: mongreldb_core::schema::ColumnFlags::empty()
2916                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
2917                default_value: None,
2918            }],
2919            indexes: vec![],
2920            colocation: vec![],
2921            constraints: Default::default(),
2922            clustered: false,
2923        };
2924        db.create_table("items", table_schema).unwrap();
2925        // Write a row to generate WAL records.
2926        let handle = db.table("items").unwrap();
2927        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
2928        handle.lock().flush().unwrap();
2929
2930        let app = build_app(db);
2931        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2932        let addr = listener.local_addr().unwrap();
2933        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2934        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2935
2936        let resp = reqwest::get(format!("http://{addr}/wal/stream"))
2937            .await
2938            .unwrap();
2939        assert_eq!(resp.status(), 200);
2940        let body = resp.text().await.unwrap();
2941        // Should contain at least one record (the flush commit).
2942        assert!(!body.is_empty(), "wal_stream should return records");
2943        assert!(body.contains("seq"), "response should contain seq field");
2944    }
2945
2946    #[tokio::test]
2947    async fn follower_bootstraps_and_applies_incremental_commit() {
2948        let leader_dir = tempdir().unwrap();
2949        let follower_dir = tempdir().unwrap();
2950        let follower_path = follower_dir.path().join("copy");
2951        let db = Arc::new(Database::create(leader_dir.path()).unwrap());
2952        db.create_table(
2953            "items",
2954            mongreldb_core::schema::Schema {
2955                schema_id: 1,
2956                columns: vec![mongreldb_core::schema::ColumnDef {
2957                    id: 1,
2958                    name: "id".into(),
2959                    ty: TypeId::Int64,
2960                    flags: mongreldb_core::schema::ColumnFlags::empty()
2961                        .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
2962                    default_value: None,
2963                }],
2964                indexes: vec![],
2965                colocation: vec![],
2966                constraints: Default::default(),
2967                clustered: false,
2968            },
2969        )
2970        .unwrap();
2971        let handle = db.table("items").unwrap();
2972        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
2973        handle.lock().commit().unwrap();
2974
2975        let app = build_app_with_config(
2976            Arc::clone(&db),
2977            std::iter::empty::<Arc<dyn ExternalTableModule>>(),
2978            Some("replication-secret".into()),
2979            None,
2980        );
2981        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2982        let addr = listener.local_addr().unwrap();
2983        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2984        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2985
2986        let leader_url = format!("http://{addr}");
2987        let first_path = follower_path.clone();
2988        let (mut follower, initial) = tokio::task::spawn_blocking(move || {
2989            let mut follower = ReplicationFollower::new(&leader_url, first_path)
2990                .with_bearer_token("replication-secret");
2991            let applied = follower.sync().unwrap();
2992            (follower, applied)
2993        })
2994        .await
2995        .unwrap();
2996        assert_eq!(initial, 0);
2997
2998        handle.lock().put(vec![(1, Value::Int64(2))]).unwrap();
2999        handle.lock().commit().unwrap();
3000        let applied = tokio::task::spawn_blocking(move || {
3001            let count = follower.sync().unwrap();
3002            (follower, count)
3003        })
3004        .await
3005        .unwrap();
3006        follower = applied.0;
3007        assert!(applied.1 > 0);
3008        assert!(follower.last_epoch() > 0);
3009
3010        let replica = Database::open(&follower_path).unwrap();
3011        assert_eq!(replica.table("items").unwrap().lock().count(), 2);
3012        drop(replica);
3013
3014        db.set_spill_threshold(1);
3015        db.transaction(|txn| {
3016            txn.put("items", vec![(1, Value::Int64(3))])?;
3017            Ok(())
3018        })
3019        .unwrap();
3020        let (follower_after_bootstrap, applied) = tokio::task::spawn_blocking(move || {
3021            let count = follower.sync().unwrap();
3022            (follower, count)
3023        })
3024        .await
3025        .unwrap();
3026        assert_eq!(applied, 0, "spilled run should trigger safe rebootstrap");
3027        assert!(follower_after_bootstrap.last_epoch() > 0);
3028        let replica = Database::open(&follower_path).unwrap();
3029        assert_eq!(replica.table("items").unwrap().lock().count(), 3);
3030    }
3031}
3032
3033#[cfg(test)]
3034mod metrics_tests {
3035    use super::*;
3036    use mongreldb_core::Database;
3037    use tempfile::tempdir;
3038
3039    /// Helper: spin up a daemon over a fresh DB with one `items(id int64 pk)`
3040    /// table pre-created, returning the bound address.
3041    async fn setup() -> std::net::SocketAddr {
3042        let dir = tempdir().unwrap();
3043        let db = Arc::new(Database::create(dir.path()).unwrap());
3044        let table_schema = mongreldb_core::schema::Schema {
3045            schema_id: 1,
3046            columns: vec![mongreldb_core::schema::ColumnDef {
3047                id: 1,
3048                name: "id".into(),
3049                ty: TypeId::Int64,
3050                flags: mongreldb_core::schema::ColumnFlags::empty()
3051                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
3052                default_value: None,
3053            }],
3054            indexes: vec![],
3055            colocation: vec![],
3056            constraints: Default::default(),
3057            clustered: false,
3058        };
3059        db.create_table("items", table_schema).unwrap();
3060        let app = build_app(db);
3061        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3062        let addr = listener.local_addr().unwrap();
3063        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3064        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3065        addr
3066    }
3067
3068    #[tokio::test]
3069    async fn metrics_endpoint_returns_prometheus_text() {
3070        let addr = setup().await;
3071        let client = reqwest::Client::new();
3072
3073        // Exercise a few handlers to bump counters.
3074        let _ = client
3075            .post(format!("http://{addr}/tables/items/put"))
3076            .json(&json!({ "row": [1, 1] }))
3077            .send()
3078            .await
3079            .unwrap();
3080        let _ = client
3081            .post(format!("http://{addr}/sql"))
3082            .json(&json!({ "sql": "SELECT count(*) FROM items" }))
3083            .send()
3084            .await
3085            .unwrap();
3086
3087        let resp = client
3088            .get(format!("http://{addr}/metrics"))
3089            .send()
3090            .await
3091            .unwrap();
3092        assert_eq!(resp.status(), 200);
3093        let ct = resp
3094            .headers()
3095            .get("content-type")
3096            .and_then(|v| v.to_str().ok())
3097            .unwrap_or_default()
3098            .to_string();
3099        assert!(
3100            ct.contains("text/plain"),
3101            "content-type is prometheus text: {ct}"
3102        );
3103        let body = resp.text().await.unwrap();
3104        // Prometheus series + type lines are present.
3105        assert!(body.contains("# TYPE mongreldb_sql_queries_total counter"));
3106        assert!(body.contains("# TYPE mongreldb_puts_total counter"));
3107        assert!(body.contains("# TYPE mongreldb_tables gauge"));
3108        // Counters were bumped: at least one query and one put were served.
3109        assert!(
3110            body.contains("mongreldb_sql_queries_total 1"),
3111            "sql_queries counter should reflect the /sql call: {body}"
3112        );
3113        assert!(
3114            body.contains("mongreldb_puts_total 1"),
3115            "puts counter should reflect the put call: {body}"
3116        );
3117        // The tables gauge reflects the single `items` table.
3118        assert!(body.contains("mongreldb_tables 1"));
3119    }
3120
3121    #[tokio::test]
3122    async fn metrics_error_counter_increments_on_bad_sql() {
3123        let addr = setup().await;
3124        let client = reqwest::Client::new();
3125        // A query against a non-existent table errors at the engine layer.
3126        let _ = client
3127            .post(format!("http://{addr}/sql"))
3128            .json(&json!({ "sql": "SELECT * FROM does_not_exist" }))
3129            .send()
3130            .await
3131            .unwrap();
3132        let body = client
3133            .get(format!("http://{addr}/metrics"))
3134            .send()
3135            .await
3136            .unwrap()
3137            .text()
3138            .await
3139            .unwrap();
3140        assert!(
3141            body.contains("mongreldb_sql_errors_total 1"),
3142            "sql_errors should increment on a failed query: {body}"
3143        );
3144    }
3145
3146    #[tokio::test]
3147    async fn arrow_stream_returns_ipc_stream_bytes() {
3148        let addr = setup().await;
3149        let client = reqwest::Client::new();
3150        // Insert a couple of rows so there are real batches to stream, then
3151        // flush so the rows are durable/visible to a fresh SQL session.
3152        for i in 1..=3 {
3153            let resp = client
3154                .post(format!("http://{addr}/tables/items/put"))
3155                .json(&json!({ "row": [1, i] }))
3156                .send()
3157                .await
3158                .unwrap();
3159            assert_eq!(resp.status(), 200, "put should succeed");
3160        }
3161        let _ = client
3162            .post(format!("http://{addr}/tables/items/commit"))
3163            .send()
3164            .await
3165            .unwrap();
3166        // Sanity: the rows are durable and visible to the table handle.
3167        let count_body = client
3168            .get(format!("http://{addr}/tables/items/count"))
3169            .send()
3170            .await
3171            .unwrap()
3172            .text()
3173            .await
3174            .unwrap();
3175        assert!(
3176            count_body.contains("\"count\":3"),
3177            "expected 3 visible rows, got: {count_body}"
3178        );
3179        let resp = client
3180            .post(format!("http://{addr}/sql"))
3181            .json(&json!({ "sql": "SELECT count(*) FROM items", "format": "arrow-stream" }))
3182            .send()
3183            .await
3184            .unwrap();
3185        assert_eq!(resp.status(), 200, "streaming query should succeed");
3186        let ct = resp
3187            .headers()
3188            .get("content-type")
3189            .and_then(|v| v.to_str().ok())
3190            .unwrap_or_default()
3191            .to_string();
3192        assert!(
3193            ct.contains("application/vnd.apache.arrow.stream"),
3194            "content-type should be the arrow stream format: {ct}"
3195        );
3196        let bytes = resp.bytes().await.unwrap();
3197        // Arrow IPC streams begin with the magic continuation marker
3198        // 0xFFFFFFFF followed by the schema message length. The stream must be
3199        // non-empty (3 rows were written) and end with the EOS marker.
3200        assert!(
3201            !bytes.is_empty(),
3202            "arrow stream body should contain schema + batch + EOS"
3203        );
3204        assert!(
3205            bytes.starts_with(&0xFFFFFFFFu32.to_le_bytes()),
3206            "arrow stream must begin with the IPC continuation marker"
3207        );
3208        assert!(
3209            bytes.ends_with(&[0u8, 0, 0, 0]),
3210            "arrow stream should end with the EOS marker (trailing zero length)"
3211        );
3212    }
3213}
3214
3215#[cfg(test)]
3216mod streaming_tests {
3217    use super::*;
3218    use arrow::array::Int64Array;
3219    use arrow::datatypes::{DataType, Field, Schema};
3220    use arrow::record_batch::RecordBatch;
3221    use datafusion::common::DataFusionError;
3222    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
3223    use futures::StreamExt;
3224    use std::sync::Arc as StdArc;
3225
3226    fn batch_stream(batches: Vec<RecordBatch>) -> mongreldb_query::MongrelRecordBatchStream {
3227        let schema = batches
3228            .first()
3229            .map(RecordBatch::schema)
3230            .unwrap_or_else(|| StdArc::new(Schema::empty()));
3231        let batches =
3232            futures::stream::iter(batches.into_iter().map(Ok::<RecordBatch, DataFusionError>));
3233        Box::pin(RecordBatchStreamAdapter::new(schema, batches))
3234    }
3235
3236    /// Unit-level check: feed two synthetic batches through the streaming
3237    /// serializer and re-parse the resulting IPC stream end-to-end. This
3238    /// validates the per-message chunking (schema + N batches + EOS) without
3239    /// depending on the engine's scan visibility.
3240    #[tokio::test]
3241    async fn arrow_stream_serializes_multiple_batches_roundtrip() {
3242        let schema = StdArc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
3243        let b1 = RecordBatch::try_new(
3244            schema.clone(),
3245            vec![StdArc::new(Int64Array::from(vec![1, 2]))],
3246        )
3247        .unwrap();
3248        let b2 = RecordBatch::try_new(
3249            schema.clone(),
3250            vec![StdArc::new(Int64Array::from(vec![3, 4, 5]))],
3251        )
3252        .unwrap();
3253
3254        let resp = sql_arrow_stream_response(batch_stream(vec![b1, b2]));
3255        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
3256            .await
3257            .unwrap();
3258
3259        // Begins with the IPC continuation marker.
3260        assert!(bytes.starts_with(&0xFFFFFFFFu32.to_le_bytes()));
3261
3262        // Re-parse the full stream and confirm all rows survived the chunked
3263        // serialization.
3264        let slice: &[u8] = bytes.as_ref();
3265        let mut reader = arrow::ipc::reader::StreamReader::try_new(slice, None).unwrap();
3266        let mut total_rows = 0;
3267        for batch in reader.by_ref() {
3268            let batch = batch.expect("each IPC message should decode");
3269            total_rows += batch.num_rows();
3270        }
3271        assert_eq!(
3272            total_rows, 5,
3273            "all rows should round-trip through the stream"
3274        );
3275    }
3276
3277    #[tokio::test]
3278    async fn arrow_stream_emits_schema_before_first_batch() {
3279        let schema = StdArc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
3280        let pending = futures::stream::pending::<Result<RecordBatch, DataFusionError>>();
3281        let batches = Box::pin(RecordBatchStreamAdapter::new(schema, pending));
3282        let mut body = sql_arrow_stream_response(batches)
3283            .into_body()
3284            .into_data_stream();
3285
3286        let chunk = tokio::time::timeout(std::time::Duration::from_millis(100), body.next())
3287            .await
3288            .expect("schema chunk should not wait for a query batch")
3289            .unwrap()
3290            .unwrap();
3291        assert!(chunk.starts_with(&0xFFFFFFFFu32.to_le_bytes()));
3292    }
3293
3294    #[tokio::test]
3295    async fn arrow_stream_empty_query_is_valid_ipc() {
3296        let resp = sql_arrow_stream_response(batch_stream(Vec::new()));
3297        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
3298            .await
3299            .unwrap();
3300        let slice: &[u8] = bytes.as_ref();
3301        let reader = arrow::ipc::reader::StreamReader::try_new(slice, None).unwrap();
3302        assert_eq!(reader.count(), 0);
3303    }
3304
3305    #[tokio::test]
3306    async fn buffered_output_limits_are_typed() {
3307        let dir = tempfile::tempdir().unwrap();
3308        let db = StdArc::new(mongreldb_core::Database::create(dir.path()).unwrap());
3309        let session = MongrelSession::open(db).unwrap();
3310        let query = session.register_query(SqlQueryOptions::default()).unwrap();
3311        let output = session
3312            .run_with_query_for_serialization("SELECT 1", query)
3313            .await
3314            .unwrap();
3315
3316        let row_error = serialize_buffered_output("json", &output, 0, 1024, None).unwrap_err();
3317        assert!(matches!(row_error, BufferedSerializationError::Limit(_)));
3318        let byte_error = serialize_buffered_output("json", &output, 10, 1, None).unwrap_err();
3319        assert!(matches!(byte_error, BufferedSerializationError::Limit(_)));
3320        output.fail();
3321    }
3322}
3323
3324#[cfg(test)]
3325mod audit_tests {
3326    use super::*;
3327    use mongreldb_core::Database;
3328    use tempfile::tempdir;
3329
3330    /// Build a daemon with user-auth enabled plus one catalog user `alice`.
3331    async fn auth_setup(password: &str) -> std::net::SocketAddr {
3332        let dir = tempdir().unwrap();
3333        let db = Arc::new(Database::create(dir.path()).unwrap());
3334        db.create_user("alice", password).unwrap();
3335        db.set_user_admin("alice", true).unwrap();
3336        let app = build_app_full(
3337            db,
3338            std::iter::empty::<Arc<dyn ExternalTableModule>>(),
3339            None,
3340            None,
3341            true,
3342        );
3343        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3344        let addr = listener.local_addr().unwrap();
3345        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3346        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3347        addr
3348    }
3349
3350    #[tokio::test]
3351    async fn audit_records_login_success_and_failure() {
3352        let addr = auth_setup("s3cret").await;
3353        let client = reqwest::Client::new();
3354
3355        // Successful basic auth.
3356        let resp = client
3357            .get(format!("http://{addr}/health"))
3358            .header("Authorization", basic("alice", "s3cret"))
3359            .send()
3360            .await
3361            .unwrap();
3362        assert_eq!(resp.status(), 200);
3363
3364        // Failed basic auth (wrong password).
3365        let resp = client
3366            .get(format!("http://{addr}/health"))
3367            .header("Authorization", basic("alice", "wrong"))
3368            .send()
3369            .await
3370            .unwrap();
3371        assert_eq!(resp.status(), 401);
3372
3373        let body = client
3374            .get(format!("http://{addr}/audit"))
3375            .header("Authorization", basic("alice", "s3cret"))
3376            .send()
3377            .await
3378            .unwrap()
3379            .text()
3380            .await
3381            .unwrap();
3382        assert!(
3383            body.contains("\"action\":\"login.ok\""),
3384            "audit should record the successful login: {body}"
3385        );
3386        assert!(
3387            body.contains("\"action\":\"login.fail\""),
3388            "audit should record the failed login: {body}"
3389        );
3390        assert!(
3391            body.contains("\"principal\":\"alice\""),
3392            "audit should attribute events to alice: {body}"
3393        );
3394    }
3395
3396    #[tokio::test]
3397    async fn audit_records_ddl_sql() {
3398        let dir = tempdir().unwrap();
3399        let db = Arc::new(Database::create(dir.path()).unwrap());
3400        let app = build_app(db);
3401        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3402        let addr = listener.local_addr().unwrap();
3403        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3404        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3405
3406        let client = reqwest::Client::new();
3407        let _ = client
3408            .post(format!("http://{addr}/sql"))
3409            .json(&json!({ "sql": "CREATE TABLE t (id BIGINT PRIMARY KEY)" }))
3410            .send()
3411            .await
3412            .unwrap();
3413        // A plain SELECT is NOT audited.
3414        let _ = client
3415            .post(format!("http://{addr}/sql"))
3416            .json(&json!({ "sql": "SELECT 1" }))
3417            .send()
3418            .await
3419            .unwrap();
3420
3421        let body = client
3422            .get(format!("http://{addr}/audit"))
3423            .send()
3424            .await
3425            .unwrap()
3426            .text()
3427            .await
3428            .unwrap();
3429        assert!(
3430            body.contains("\"action\":\"ddl.ok\""),
3431            "audit should record the successful DDL statement: {body}"
3432        );
3433        assert!(
3434            body.contains("CREATE TABLE"),
3435            "audit detail should carry the DDL snippet: {body}"
3436        );
3437        // SELECT must not appear.
3438        assert!(
3439            !body.contains("SELECT 1"),
3440            "non-DDL reads should not be audited: {body}"
3441        );
3442    }
3443
3444    #[tokio::test]
3445    async fn audit_redacts_credential_passwords() {
3446        let dir = tempdir().unwrap();
3447        let db = Arc::new(Database::create(dir.path()).unwrap());
3448        let app = build_app(db);
3449        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3450        let addr = listener.local_addr().unwrap();
3451        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3452        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3453
3454        let client = reqwest::Client::new();
3455        // A CREATE USER carrying a plaintext password must be audited but the
3456        // password must NEVER reach /audit or stderr.
3457        let _ = client
3458            .post(format!("http://{addr}/sql"))
3459            .json(&json!({ "sql": "CREATE USER alice WITH PASSWORD 'topsecret'" }))
3460            .send()
3461            .await
3462            .unwrap();
3463
3464        let body = client
3465            .get(format!("http://{addr}/audit"))
3466            .send()
3467            .await
3468            .unwrap()
3469            .text()
3470            .await
3471            .unwrap();
3472        assert!(
3473            !body.contains("topsecret"),
3474            "password must never appear in the audit log: {body}"
3475        );
3476        assert!(
3477            body.contains("redacted credential statement"),
3478            "credential DDL should be recorded as redacted: {body}"
3479        );
3480    }
3481
3482    fn basic(user: &str, pass: &str) -> String {
3483        let raw = format!("{user}:{pass}");
3484        format!("Basic {}", base64_encode(raw.as_bytes()))
3485    }
3486
3487    fn base64_encode(input: &[u8]) -> String {
3488        const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3489        let mut out = String::new();
3490        let mut buf = 0u32;
3491        let mut bits = 0u32;
3492        for &b in input {
3493            buf = (buf << 8) | b as u32;
3494            bits += 8;
3495            while bits >= 6 {
3496                bits -= 6;
3497                out.push(TABLE[((buf >> bits) & 0x3F) as usize] as char);
3498            }
3499        }
3500        if bits > 0 {
3501            out.push(TABLE[((buf << (6 - bits)) & 0x3F) as usize] as char);
3502        }
3503        while !out.len().is_multiple_of(4) {
3504            out.push('=');
3505        }
3506        out
3507    }
3508}
3509
3510#[cfg(test)]
3511mod session_tests {
3512    use super::*;
3513    use mongreldb_core::Database;
3514    use tempfile::tempdir;
3515
3516    /// Spin up a daemon over a fresh DB with one `items(id int64 pk)` table.
3517    /// Returns the TempDir (must be held alive for the test's duration — dropping
3518    /// it deletes the database directory mid-test) and the bound address.
3519    async fn setup() -> (tempfile::TempDir, std::net::SocketAddr) {
3520        let dir = tempdir().unwrap();
3521        let db = Arc::new(Database::create(dir.path()).unwrap());
3522        let table_schema = mongreldb_core::schema::Schema {
3523            schema_id: 1,
3524            columns: vec![mongreldb_core::schema::ColumnDef {
3525                id: 1,
3526                name: "id".into(),
3527                ty: TypeId::Int64,
3528                flags: mongreldb_core::schema::ColumnFlags::empty()
3529                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
3530                default_value: None,
3531            }],
3532            indexes: vec![],
3533            colocation: vec![],
3534            constraints: Default::default(),
3535            clustered: false,
3536        };
3537        db.create_table("items", table_schema).unwrap();
3538        let app = build_app(db);
3539        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3540        let addr = listener.local_addr().unwrap();
3541        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3542        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3543        (dir, addr)
3544    }
3545
3546    /// Open a session and return its token.
3547    async fn open_session(client: &reqwest::Client, addr: &std::net::SocketAddr) -> String {
3548        let resp = client
3549            .post(format!("http://{addr}/sessions"))
3550            .send()
3551            .await
3552            .unwrap();
3553        assert_eq!(resp.status(), 200);
3554        resp.json::<serde_json::Value>()
3555            .await
3556            .unwrap()
3557            .get("session_id")
3558            .unwrap()
3559            .as_str()
3560            .unwrap()
3561            .to_string()
3562    }
3563
3564    async fn sql_on(
3565        client: &reqwest::Client,
3566        addr: &std::net::SocketAddr,
3567        session: &str,
3568        sql: &str,
3569    ) -> reqwest::Response {
3570        client
3571            .post(format!("http://{addr}/sql"))
3572            .header("X-Session-ID", session)
3573            .json(&json!({ "sql": sql }))
3574            .send()
3575            .await
3576            .unwrap()
3577    }
3578
3579    async fn count_items(client: &reqwest::Client, addr: &std::net::SocketAddr) -> u64 {
3580        client
3581            .get(format!("http://{addr}/tables/items/count"))
3582            .send()
3583            .await
3584            .unwrap()
3585            .json::<serde_json::Value>()
3586            .await
3587            .unwrap()
3588            .get("count")
3589            .unwrap()
3590            .as_u64()
3591            .unwrap()
3592    }
3593
3594    #[tokio::test]
3595    async fn cross_request_transaction_commits() {
3596        let (_dir, addr) = setup().await;
3597        let client = reqwest::Client::new();
3598        let session = open_session(&client, &addr).await;
3599
3600        // BEGIN, INSERT, COMMIT — each its own HTTP request on the same session.
3601        let r = sql_on(&client, &addr, &session, "BEGIN").await;
3602        assert_eq!(r.status(), 200);
3603        let r = sql_on(
3604            &client,
3605            &addr,
3606            &session,
3607            "INSERT INTO items (id) VALUES (1)",
3608        )
3609        .await;
3610        assert_eq!(r.status(), 200, "INSERT should stage successfully");
3611        // Not yet committed → not visible to other connections.
3612        assert_eq!(count_items(&client, &addr).await, 0);
3613        let r = sql_on(&client, &addr, &session, "COMMIT").await;
3614        assert_eq!(r.status(), 200);
3615
3616        // After COMMIT the row is durable and visible.
3617        assert_eq!(count_items(&client, &addr).await, 1);
3618    }
3619
3620    #[tokio::test]
3621    async fn cross_request_transaction_rolls_back() {
3622        let (_dir, addr) = setup().await;
3623        let client = reqwest::Client::new();
3624        let session = open_session(&client, &addr).await;
3625
3626        sql_on(&client, &addr, &session, "BEGIN").await;
3627        sql_on(
3628            &client,
3629            &addr,
3630            &session,
3631            "INSERT INTO items (id) VALUES (5)",
3632        )
3633        .await;
3634        // ROLLBACK discards the staged insert.
3635        let r = sql_on(&client, &addr, &session, "ROLLBACK").await;
3636        assert_eq!(r.status(), 200);
3637        assert_eq!(
3638            count_items(&client, &addr).await,
3639            0,
3640            "rollback discards staged writes"
3641        );
3642    }
3643
3644    #[tokio::test]
3645    async fn unknown_session_id_is_404() {
3646        let (_dir, addr) = setup().await;
3647        let client = reqwest::Client::new();
3648        let resp = client
3649            .post(format!("http://{addr}/sql"))
3650            .header("X-Session-ID", "does-not-exist")
3651            .json(&json!({ "sql": "SELECT 1" }))
3652            .send()
3653            .await
3654            .unwrap();
3655        assert_eq!(resp.status(), 404);
3656    }
3657
3658    #[tokio::test]
3659    async fn close_session_ends_cross_request_state() {
3660        let (_dir, addr) = setup().await;
3661        let client = reqwest::Client::new();
3662        let session = open_session(&client, &addr).await;
3663
3664        // BEGIN on the session, then close it → staged txn is discarded.
3665        sql_on(&client, &addr, &session, "BEGIN").await;
3666        let r = client
3667            .delete(format!("http://{addr}/sessions/{session}"))
3668            .send()
3669            .await
3670            .unwrap();
3671        assert_eq!(r.status(), 200);
3672
3673        // The session token is now invalid.
3674        let resp = sql_on(&client, &addr, &session, "COMMIT").await;
3675        assert_eq!(resp.status(), 404, "closed session is no longer usable");
3676    }
3677
3678    #[tokio::test]
3679    async fn no_session_header_uses_fresh_ephemeral_session() {
3680        // Without X-Session-ID, BEGIN..COMMIT must still work within a single
3681        // multi-statement /sql body (the historical behavior is preserved).
3682        let (_dir, addr) = setup().await;
3683        let client = reqwest::Client::new();
3684        let resp = client
3685            .post(format!("http://{addr}/sql"))
3686            .json(&json!({ "sql": "INSERT INTO items (id) VALUES (42)" }))
3687            .send()
3688            .await
3689            .unwrap();
3690        assert_eq!(resp.status(), 200);
3691        assert_eq!(count_items(&client, &addr).await, 1);
3692    }
3693
3694    #[tokio::test]
3695    async fn prepared_statement_prepare_execute_and_reuse() {
3696        let (_dir, addr) = setup().await;
3697        let client = reqwest::Client::new();
3698        let session = open_session(&client, &addr).await;
3699        sql_on(
3700            &client,
3701            &addr,
3702            &session,
3703            "INSERT INTO items (id) VALUES (1), (2), (3), (4)",
3704        )
3705        .await;
3706
3707        // Prepare a parameterized query once.
3708        let resp = client
3709            .post(format!("http://{addr}/sessions/{session}/prepare"))
3710            .json(&json!({"name":"gt","sql":"SELECT id FROM items WHERE id > $1"}))
3711            .send()
3712            .await
3713            .unwrap();
3714        assert_eq!(resp.status(), 200);
3715
3716        // Execute with param 2 → ids 3,4.
3717        let resp = client
3718            .post(format!("http://{addr}/sessions/{session}/execute"))
3719            .json(&json!({"name":"gt","params":[2]}))
3720            .send()
3721            .await
3722            .unwrap();
3723        assert_eq!(resp.status(), 200);
3724        let body = resp.json::<serde_json::Value>().await.unwrap();
3725        let arr = body
3726            .as_array()
3727            .expect("execute returns a JSON array of rows");
3728        assert_eq!(arr.len(), 2, "ids > 2 are {{3,4}}: {body}");
3729
3730        // Re-execute with a different param → reuses the cached plan, fewer rows.
3731        let resp = client
3732            .post(format!("http://{addr}/sessions/{session}/execute"))
3733            .json(&json!({"name":"gt","params":[3]}))
3734            .send()
3735            .await
3736            .unwrap();
3737        let body = resp.json::<serde_json::Value>().await.unwrap();
3738        assert_eq!(body.as_array().unwrap().len(), 1, "ids > 3 is {{4}}");
3739    }
3740
3741    #[tokio::test]
3742    async fn prepared_statement_deallocate_then_execute_fails() {
3743        let (_dir, addr) = setup().await;
3744        let client = reqwest::Client::new();
3745        let session = open_session(&client, &addr).await;
3746        let _ = client
3747            .post(format!("http://{addr}/sessions/{session}/prepare"))
3748            .json(&json!({"name":"p","sql":"SELECT $1"}))
3749            .send()
3750            .await
3751            .unwrap();
3752        let resp = client
3753            .delete(format!("http://{addr}/sessions/{session}/statements/p"))
3754            .send()
3755            .await
3756            .unwrap();
3757        assert_eq!(resp.status(), 200);
3758        // Execute after deallocate must error.
3759        let resp = client
3760            .post(format!("http://{addr}/sessions/{session}/execute"))
3761            .json(&json!({"name":"p","params":[1]}))
3762            .send()
3763            .await
3764            .unwrap();
3765        assert_ne!(resp.status(), 200, "execute after DEALLOCATE must fail");
3766    }
3767
3768    #[tokio::test]
3769    async fn prepared_statement_rejects_bad_name() {
3770        let (_dir, addr) = setup().await;
3771        let client = reqwest::Client::new();
3772        let session = open_session(&client, &addr).await;
3773        let resp = client
3774            .post(format!("http://{addr}/sessions/{session}/prepare"))
3775            .json(&json!({"name":"1bad","sql":"SELECT 1"}))
3776            .send()
3777            .await
3778            .unwrap();
3779        assert_eq!(
3780            resp.status(),
3781            400,
3782            "statement name starting with a digit must be rejected"
3783        );
3784    }
3785}