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::Arc;
18
19use axum::extract::{Path, State};
20use axum::http::header;
21use axum::http::StatusCode;
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use axum::Json;
25use mongreldb_core::schema::{Schema, TypeId};
26use mongreldb_core::{Database, Value};
27use mongreldb_query::{ExternalTableModule, MongrelSession};
28use serde::Deserialize;
29use serde_json::json;
30
31mod audit;
32mod kit;
33mod metrics;
34mod procedure;
35mod sessions;
36mod trigger;
37
38pub use sessions::{spawn_session_reaper, SessionStore};
39
40/// Map an engine error to the appropriate HTTP status code for defense-in-depth.
41/// Auth errors get 401/403; everything else stays 500. This ensures that even
42/// after the HTTP auth middleware lets a request through, the storage layer's
43/// permission checks surface as the right status (not a generic 500).
44fn status_for_error(e: &mongreldb_core::MongrelError) -> StatusCode {
45    use mongreldb_core::MongrelError;
46    match e {
47        MongrelError::AuthRequired | MongrelError::InvalidCredentials { .. } => {
48            StatusCode::UNAUTHORIZED
49        }
50        MongrelError::AuthNotRequired => StatusCode::BAD_REQUEST,
51        MongrelError::PermissionDenied { .. } => StatusCode::FORBIDDEN,
52        MongrelError::ReadOnlyReplica => StatusCode::CONFLICT,
53        _ => StatusCode::INTERNAL_SERVER_ERROR,
54    }
55}
56
57/// Map a query-layer error (which wraps engine errors via `Core(...)`) to the
58/// appropriate HTTP status code.
59fn status_for_query_error(e: &mongreldb_query::MongrelQueryError) -> StatusCode {
60    use mongreldb_query::MongrelQueryError;
61    match e {
62        MongrelQueryError::Core(core) => status_for_error(core),
63        _ => StatusCode::INTERNAL_SERVER_ERROR,
64    }
65}
66
67/// Extractor that pulls the authenticated [`mongreldb_core::Principal`] (if the
68/// auth middleware injected one) from request extensions without erroring when
69/// absent (e.g. token-authenticated requests carry no `Principal`).
70struct OptionalPrincipal(Option<mongreldb_core::Principal>);
71
72impl<S> axum::extract::FromRequestParts<S> for OptionalPrincipal
73where
74    S: Send + Sync,
75{
76    type Rejection = std::convert::Infallible;
77
78    async fn from_request_parts(
79        parts: &mut axum::http::request::Parts,
80        _state: &S,
81    ) -> Result<Self, Self::Rejection> {
82        Ok(OptionalPrincipal(
83            parts.extensions.get::<mongreldb_core::Principal>().cloned(),
84        ))
85    }
86}
87
88struct AppState {
89    db: Arc<Database>,
90    idem: kit::IdempotencyStore,
91    external_modules: Vec<Arc<dyn ExternalTableModule>>,
92    auth_token: Option<String>,
93    /// When true, authenticate via catalog users (HTTP Basic auth).
94    user_auth: bool,
95    /// Daemon-wide Prometheus-style counters, shared by all handlers.
96    metrics: Arc<metrics::Metrics>,
97    /// `/sql` requests slower than this are logged as slow queries.
98    slow_query_threshold: std::time::Duration,
99    /// Bounded security audit log (auth + DDL/privilege events).
100    audit: Arc<audit::AuditLog>,
101    /// Token-keyed pool of live sessions for cross-request interactive
102    /// transactions (`X-Session-ID` on `/sql`).
103    sessions: Arc<sessions::SessionStore>,
104}
105
106pub fn build_app(db: Arc<Database>) -> axum::Router {
107    build_app_with_config(
108        db,
109        std::iter::empty::<Arc<dyn ExternalTableModule>>(),
110        None,
111        None,
112    )
113}
114
115pub fn build_app_with_external_modules(
116    db: Arc<Database>,
117    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
118) -> axum::Router {
119    build_app_with_config(db, external_modules, None, None)
120}
121
122/// Build the daemon router with optional auth token and max-connections limit.
123pub fn build_app_with_config(
124    db: Arc<Database>,
125    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
126    auth_token: Option<String>,
127    max_connections: Option<usize>,
128) -> axum::Router {
129    build_app_full(db, external_modules, auth_token, max_connections, false)
130}
131
132/// Build the daemon router with full auth configuration including user-based auth.
133/// Sessions are enabled with a default capacity (256) and idle timeout (300 s).
134pub fn build_app_full(
135    db: Arc<Database>,
136    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
137    auth_token: Option<String>,
138    max_connections: Option<usize>,
139    user_auth: bool,
140) -> axum::Router {
141    let sessions = Arc::new(sessions::SessionStore::new(
142        default_max_sessions(),
143        default_session_idle_timeout(),
144    ));
145    build_app_with_sessions(
146        db,
147        external_modules,
148        auth_token,
149        max_connections,
150        user_auth,
151        sessions,
152    )
153}
154
155/// Build the daemon router with an explicit, externally-owned session store.
156/// The caller (typically `main`) keeps the `Arc<SessionStore>` so it can spawn
157/// the idle reaper against the same map the handlers use.
158pub fn build_app_with_sessions(
159    db: Arc<Database>,
160    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
161    auth_token: Option<String>,
162    max_connections: Option<usize>,
163    user_auth: bool,
164    sessions: Arc<sessions::SessionStore>,
165) -> axum::Router {
166    db.set_replication_wal_retention_segments(default_replication_wal_segments());
167    if let Err(error) = db.set_history_retention_epochs(default_history_retention_epochs()) {
168        eprintln!("[history] failed to configure retention: {error}");
169    }
170    let state = Arc::new(AppState {
171        idem: kit::IdempotencyStore::new(db.root()),
172        db,
173        external_modules: external_modules.into_iter().collect(),
174        auth_token,
175        user_auth,
176        metrics: Arc::new(metrics::Metrics::default()),
177        slow_query_threshold: metrics::slow_query_threshold(),
178        audit: Arc::new(audit::AuditLog::new(8192)),
179        sessions,
180    });
181    let router = axum::Router::new()
182        .route("/health", get(health))
183        .route("/metrics", get(metrics_handler))
184        .route("/audit", get(audit_handler))
185        .route("/tables", get(list_tables).post(create_table))
186        .route("/tables/{name}", axum::routing::delete(drop_table))
187        .route("/tables/{name}/put", post(put_row))
188        .route("/tables/{name}/count", get(count))
189        .route("/tables/{name}/commit", post(commit))
190        .route("/sql", post(sql))
191        .route("/txn", post(txn))
192        .route("/sessions", post(create_session))
193        .route("/sessions/{id}", axum::routing::delete(close_session))
194        .route("/sessions/{id}/prepare", post(prepare_statement))
195        .route("/sessions/{id}/execute", post(execute_statement))
196        .route(
197            "/sessions/{id}/statements/{name}",
198            axum::routing::delete(deallocate_statement),
199        )
200        .route("/procedures", get(procedure::list).post(procedure::create))
201        .route(
202            "/procedures/{name}",
203            get(procedure::describe)
204                .put(procedure::replace)
205                .delete(procedure::drop_procedure),
206        )
207        .route("/procedures/{name}/call", post(procedure::call))
208        .route("/triggers", get(trigger::list).post(trigger::create))
209        .route(
210            "/triggers/{name}",
211            get(trigger::describe)
212                .put(trigger::replace)
213                .delete(trigger::drop_trigger),
214        )
215        // Typed Kit-aware surface (authoritative validation + constraints).
216        .route("/kit/schema", get(kit::schema_all))
217        .route("/kit/schema/{table}", get(kit::schema_one))
218        .route("/kit/txn", post(kit::kit_txn))
219        .route("/kit/query", post(kit::kit_query))
220        .route("/kit/create_table", post(kit::kit_create_table))
221        .route("/kit/procedures/{name}/call", post(procedure::kit_call))
222        .route("/compact", post(compact_all))
223        .route("/tables/{name}/compact", post(compact_table))
224        .route("/wal/stream", get(wal_stream))
225        .route("/replication/snapshot", get(replication_snapshot))
226        .route("/events", get(events_stream))
227        .with_state(state.clone());
228
229    // Apply auth middleware if token auth or user auth is enabled.
230    let router = if state.auth_token.is_some() || state.user_auth {
231        router.layer(axum::middleware::from_fn_with_state(
232            state.clone(),
233            auth_middleware,
234        ))
235    } else {
236        router
237    };
238
239    // Apply connection limit if configured.
240    if let Some(max) = max_connections {
241        router.layer(tower::limit::ConcurrencyLimitLayer::new(max))
242    } else {
243        router
244    }
245}
246
247/// Auth middleware supporting three modes:
248/// 1. **Token** (`--auth-token <token>`): checks `Authorization: Bearer <token>`.
249/// 2. **User auth** (`--auth-users`): checks `Authorization: Basic <base64(user:pass)>`
250///    against catalog users (Argon2id-verified). Injects a `Principal` into
251///    request extensions.
252/// 3. **Both**: token OR valid user credentials accepted.
253async fn auth_middleware(
254    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
255    mut req: axum::extract::Request,
256    next: axum::middleware::Next,
257) -> Result<axum::response::Response, axum::http::StatusCode> {
258    let header = req
259        .headers()
260        .get("authorization")
261        .and_then(|v| v.to_str().ok())
262        .unwrap_or("");
263
264    // Track the attempted identity + failure reason so EVERY 401 path emits
265    // exactly one `login.fail` audit event (missing, malformed, wrong token,
266    // wrong password are all logged — no unauthenticated probe goes unrecorded).
267    let mut attempted = String::new();
268    let mut fail_reason = "no credentials provided".to_string();
269
270    // Mode 1: Token auth (Bearer).
271    if let Some(token) = &state.auth_token {
272        if let Some(provided) = header.strip_prefix("Bearer ") {
273            attempted = "token".to_string();
274            if provided == token {
275                state
276                    .audit
277                    .record("token", "login.ok", "bearer token accepted");
278                return Ok(next.run(req).await);
279            }
280            fail_reason = "invalid bearer token".to_string();
281        }
282    }
283
284    // Mode 2: User auth (Basic).
285    if state.user_auth {
286        if let Some(encoded) = header.strip_prefix("Basic ") {
287            if let Ok(decoded) = base64_decode(encoded) {
288                if let Ok(creds) = std::str::from_utf8(&decoded) {
289                    if let Some((username, password)) = creds.split_once(':') {
290                        attempted = username.to_string();
291                        if let Ok(Some(_user)) = state.db.verify_user(username, password) {
292                            state
293                                .audit
294                                .record(username, "login.ok", "basic credentials accepted");
295                            // Inject the principal for permission checks.
296                            if let Some(principal) = state.db.resolve_principal(username) {
297                                req.extensions_mut().insert(principal);
298                            }
299                            return Ok(next.run(req).await);
300                        }
301                        fail_reason = "invalid basic credentials".to_string();
302                    } else {
303                        fail_reason = "malformed basic credentials (no ':')".to_string();
304                    }
305                } else {
306                    fail_reason = "malformed basic credentials (non-utf8)".to_string();
307                }
308            } else {
309                fail_reason = "malformed basic credentials (bad base64)".to_string();
310            }
311        }
312    }
313
314    let who = if attempted.is_empty() {
315        "anonymous"
316    } else {
317        attempted.as_str()
318    };
319    state.audit.record(who, "login.fail", fail_reason);
320    Err(axum::http::StatusCode::UNAUTHORIZED)
321}
322
323/// Minimal Base64 decoder (no extra dep).
324fn base64_decode(input: &str) -> Result<Vec<u8>, ()> {
325    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
326    let input: Vec<u8> = input
327        .bytes()
328        .filter(|&b| b != b'\n' && b != b'\r' && b != b' ')
329        .collect();
330    let mut out = Vec::with_capacity(input.len() * 3 / 4);
331    let mut buf = 0u32;
332    let mut bits = 0u32;
333    for &b in &input {
334        if b == b'=' {
335            break;
336        }
337        let val = TABLE.iter().position(|&t| t == b).ok_or(())? as u32;
338        buf = (buf << 6) | val;
339        bits += 6;
340        if bits >= 8 {
341            bits -= 8;
342            out.push((buf >> bits) as u8);
343        }
344    }
345    Ok(out)
346}
347
348/// `GET /wal/stream?since=<epoch>` — return complete committed WAL
349/// transactions after the follower epoch. A 409 response means retained WAL
350/// cannot close the gap and the follower must fetch `/replication/snapshot`.
351/// Records are newline-delimited JSON.
352/// newline-delimited JSON for replication followers. Each line is a JSON
353/// object `{ "seq": N, "txn_id": N, "op": {...} }`.
354async fn wal_stream(
355    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
356    OptionalPrincipal(principal): OptionalPrincipal,
357    axum::extract::Query(params): axum::extract::Query<WalStreamParams>,
358) -> Result<Response, StatusCode> {
359    state
360        .db
361        .require_for(
362            request_principal(&state, &principal).as_ref(),
363            &mongreldb_core::Permission::Admin,
364        )
365        .map_err(|error| status_for_error(&error))?;
366    let since = params.since.unwrap_or(0);
367    let db = Arc::clone(&state.db);
368    let batch = tokio::task::spawn_blocking(move || db.replication_batch_since(since))
369        .await
370        .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
371    let batch = batch.map_err(|e| {
372        eprintln!("wal_stream error: {e}");
373        StatusCode::INTERNAL_SERVER_ERROR
374    })?;
375    if batch.requires_snapshot {
376        let mut response = (
377            StatusCode::CONFLICT,
378            "replication snapshot required: WAL retention gap or spilled run",
379        )
380            .into_response();
381        set_replication_headers(&mut response, batch.current_epoch, batch.earliest_epoch);
382        return Ok(response);
383    }
384    let mut body = String::new();
385    for record in &batch.records {
386        let json = serde_json::to_string(record).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
387        body.push_str(&json);
388        body.push('\n');
389    }
390    let mut response = (
391        [
392            (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
393            (header::CACHE_CONTROL, "no-cache".to_string()),
394        ],
395        body,
396    )
397        .into_response();
398    set_replication_headers(&mut response, batch.current_epoch, batch.earliest_epoch);
399    Ok(response)
400}
401
402async fn replication_snapshot(
403    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
404    OptionalPrincipal(principal): OptionalPrincipal,
405) -> Response {
406    if let Err(error) = state.db.require_for(
407        request_principal(&state, &principal).as_ref(),
408        &mongreldb_core::Permission::Admin,
409    ) {
410        return (status_for_error(&error), error.to_string()).into_response();
411    }
412    let db = Arc::clone(&state.db);
413    let snapshot = match tokio::task::spawn_blocking(move || db.replication_snapshot()).await {
414        Ok(Ok(snapshot)) => snapshot,
415        Ok(Err(error)) => {
416            return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
417        }
418        Err(error) => {
419            return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
420        }
421    };
422    let epoch = snapshot.epoch();
423    // ponytail: bootstrap buffers one image; add framed file streaming when
424    // real snapshot sizes make this memory ceiling measurable.
425    match snapshot.encode() {
426        Ok(bytes) => {
427            let mut response =
428                ([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response();
429            set_replication_headers(&mut response, epoch, None);
430            response
431        }
432        Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
433    }
434}
435
436fn set_replication_headers(response: &mut Response, current: u64, earliest: Option<u64>) {
437    response.headers_mut().insert(
438        "x-mongreldb-current-epoch",
439        current.to_string().parse().unwrap(),
440    );
441    if let Some(earliest) = earliest {
442        response.headers_mut().insert(
443            "x-mongreldb-earliest-epoch",
444            earliest.to_string().parse().unwrap(),
445        );
446    }
447}
448
449#[derive(serde::Deserialize)]
450struct WalStreamParams {
451    since: Option<u64>,
452}
453
454/// `GET /events` — long-lived SSE for durable WAL-backed row changes plus
455/// ephemeral SQL NOTIFY messages. `Last-Event-ID` resumes from a stable
456/// `<commit_epoch>:<operation_index>` id. A retention gap returns 409 before
457/// the stream starts, or a terminal `gap` SSE if the client falls behind.
458async fn events_stream(
459    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
460    OptionalPrincipal(principal): OptionalPrincipal,
461    headers: axum::http::HeaderMap,
462) -> Result<Response, StatusCode> {
463    use axum::response::sse::{Event, KeepAlive, Sse};
464    use futures::Stream;
465    use std::collections::VecDeque;
466    use std::convert::Infallible;
467
468    state
469        .db
470        .require_for(
471            request_principal(&state, &principal).as_ref(),
472            &mongreldb_core::Permission::Admin,
473        )
474        .map_err(|error| status_for_error(&error))?;
475
476    struct State {
477        db: Arc<Database>,
478        receiver: tokio::sync::broadcast::Receiver<mongreldb_core::ChangeEvent>,
479        change_wake: tokio::sync::broadcast::Receiver<()>,
480        interval: tokio::time::Interval,
481        pending: VecDeque<mongreldb_core::ChangeEvent>,
482        last_id: Option<String>,
483        poll_now: bool,
484        done: bool,
485    }
486
487    fn event(change: mongreldb_core::ChangeEvent) -> Event {
488        let id = change.id.clone();
489        let kind = if change.op == "notify" {
490            "notify"
491        } else {
492            "change"
493        };
494        let mut event = Event::default().event(kind).data(
495            serde_json::to_string(&change)
496                .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#)),
497        );
498        if let Some(id) = id {
499            event = event.id(id);
500        }
501        event
502    }
503
504    let last_id = headers
505        .get("last-event-id")
506        .and_then(|value| value.to_str().ok())
507        .map(str::to_string);
508    let receiver = state.db.subscribe_changes();
509    let change_wake = state.db.subscribe_change_commits();
510    let db = Arc::clone(&state.db);
511    let resume = last_id.clone();
512    let initial = tokio::task::spawn_blocking(move || db.change_events_since(resume.as_deref()))
513        .await
514        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
515        .map_err(|error| match error {
516            mongreldb_core::MongrelError::InvalidArgument(_) => StatusCode::BAD_REQUEST,
517            _ => StatusCode::INTERNAL_SERVER_ERROR,
518        })?;
519    if initial.gap {
520        return Ok((
521            StatusCode::CONFLICT,
522            Json(json!({
523                "error": "cdc retention gap",
524                "earliest_epoch": initial.earliest_epoch,
525                "current_epoch": initial.current_epoch,
526            })),
527        )
528            .into_response());
529    }
530
531    let stream: std::pin::Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>> = Box::pin(
532        futures::stream::unfold(
533            State {
534                db: Arc::clone(&state.db),
535                receiver,
536                change_wake,
537                interval: tokio::time::interval(std::time::Duration::from_millis(250)),
538                pending: initial.events.into(),
539                last_id,
540                poll_now: false,
541                done: false,
542            },
543            |mut stream| async move {
544                if stream.done {
545                    return None;
546                }
547                loop {
548                    if stream.poll_now {
549                        stream.poll_now = false;
550                        let db = Arc::clone(&stream.db);
551                        let last_id = stream.last_id.clone();
552                        match tokio::task::spawn_blocking(move || {
553                            db.change_events_since(last_id.as_deref())
554                        })
555                        .await
556                        {
557                            Ok(Ok(batch)) if batch.gap => {
558                                stream.done = true;
559                                let gap = Event::default().event("gap").data(
560                                    json!({
561                                        "error": "cdc retention gap",
562                                        "earliest_epoch": batch.earliest_epoch,
563                                        "current_epoch": batch.current_epoch,
564                                    })
565                                    .to_string(),
566                                );
567                                return Some((Ok(gap), stream));
568                            }
569                            Ok(Ok(batch)) => stream.pending.extend(batch.events),
570                            Ok(Err(error)) => {
571                                stream.done = true;
572                                return Some((
573                                    Ok(Event::default().event("error").data(error.to_string())),
574                                    stream,
575                                ));
576                            }
577                            Err(error) => {
578                                stream.done = true;
579                                return Some((
580                                    Ok(Event::default().event("error").data(error.to_string())),
581                                    stream,
582                                ));
583                            }
584                        }
585                    }
586                    if let Some(change) = stream.pending.pop_front() {
587                        if let Some(id) = &change.id {
588                            stream.last_id = Some(id.clone());
589                        }
590                        return Some((Ok(event(change)), stream));
591                    }
592                    tokio::select! {
593                        received = stream.receiver.recv() => {
594                            match received {
595                                Ok(change) => return Some((Ok(event(change)), stream)),
596                                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {},
597                                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
598                                    return None;
599                                }
600                            }
601                        }
602                        received = stream.change_wake.recv() => {
603                            match received {
604                                Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
605                                    stream.poll_now = true;
606                                }
607                                Err(tokio::sync::broadcast::error::RecvError::Closed) => {}
608                            }
609                        }
610                        _ = stream.interval.tick() => {
611                            stream.poll_now = true;
612                        }
613                    }
614                }
615            },
616        ),
617    );
618
619    Ok(Sse::new(stream)
620        .keep_alive(
621            KeepAlive::new()
622                .interval(std::time::Duration::from_secs(15))
623                .text("keep-alive"),
624        )
625        .into_response())
626}
627
628/// Launch the §5.9 background auto-compaction sweep (run-count cost trigger).
629/// One OS thread, sleeping `interval` between sweeps; each tick locks each
630/// table individually and calls `Table::maybe_compact`. Best-effort: a
631/// compaction error is logged and never aborts the sweep.
632pub fn spawn_auto_compactor(db: Arc<Database>) {
633    std::thread::Builder::new()
634        .name("mongreldb-auto-compact".into())
635        .spawn(move || loop {
636            std::thread::sleep(std::time::Duration::from_secs(30));
637            for name in db.table_names() {
638                let Ok(handle) = db.table(&name) else {
639                    continue;
640                };
641                let mut t = handle.lock();
642                let before = t.run_count();
643                match t.maybe_compact() {
644                    Ok(true) => {
645                        eprintln!(
646                            "[auto-compact] {name}: {} runs -> {}",
647                            before,
648                            t.run_count()
649                        );
650                    }
651                    Ok(false) => {}
652                    Err(e) => {
653                        eprintln!("[auto-compact] {name}: compaction failed: {e}");
654                    }
655                }
656            }
657        })
658        .expect("spawn auto-compact thread");
659}
660
661async fn health() -> StatusCode {
662    StatusCode::OK
663}
664
665/// `GET /audit` — recent security-audit events (auth + DDL/privilege) as a JSON
666/// array, oldest-first. Subject to the same auth middleware as every other
667/// route. This is a best-effort in-memory ring buffer, not a tamper-evident
668/// log (see `audit` module docs).
669async fn audit_handler(
670    State(state): State<Arc<AppState>>,
671    OptionalPrincipal(principal): OptionalPrincipal,
672) -> Response {
673    if let Err(error) = state.db.require_for(
674        request_principal(&state, &principal).as_ref(),
675        &mongreldb_core::Permission::Admin,
676    ) {
677        return (status_for_error(&error), error.to_string()).into_response();
678    }
679    let recent = state.audit.recent();
680    Json(recent).into_response()
681}
682
683/// Default max live sessions when `--max-sessions` is not given.
684fn default_max_sessions() -> usize {
685    std::env::var("MONGRELBL_MAX_SESSIONS")
686        .ok()
687        .and_then(|v| v.parse().ok())
688        .unwrap_or(256)
689}
690
691/// Default idle-session timeout when `--session-idle-timeout` is not given.
692fn default_session_idle_timeout() -> std::time::Duration {
693    std::time::Duration::from_secs(
694        std::env::var("MONGRELBL_SESSION_IDLE_TIMEOUT_SECS")
695            .ok()
696            .and_then(|v| v.parse().ok())
697            .unwrap_or(300),
698    )
699}
700
701fn default_replication_wal_segments() -> usize {
702    let replication = std::env::var("MONGRELDB_REPLICATION_WAL_SEGMENTS")
703        .ok()
704        .and_then(|value| value.parse().ok())
705        .unwrap_or(16);
706    let cdc = std::env::var("MONGRELDB_CDC_WAL_SEGMENTS")
707        .ok()
708        .and_then(|value| value.parse().ok())
709        .unwrap_or(16);
710    replication.max(cdc)
711}
712
713fn default_history_retention_epochs() -> u64 {
714    std::env::var("MONGRELDB_HISTORY_RETENTION_EPOCHS")
715        .ok()
716        .and_then(|value| value.parse().ok())
717        .unwrap_or(1024)
718}
719
720/// The principal a request is attributed to, for session ownership: a resolved
721/// user principal wins; otherwise `token` when token auth is active; else
722/// `anonymous` (no auth configured).
723fn request_owner(state: &AppState, principal: &Option<mongreldb_core::Principal>) -> String {
724    if let Some(p) = principal {
725        return p.username.clone();
726    }
727    if state.auth_token.is_some() {
728        return "token".into();
729    }
730    "anonymous".into()
731}
732
733fn request_principal(
734    state: &AppState,
735    principal: &Option<mongreldb_core::Principal>,
736) -> Option<mongreldb_core::Principal> {
737    principal.clone().or_else(|| {
738        state
739            .auth_token
740            .as_ref()
741            .map(|_| mongreldb_core::Principal {
742                username: "token".into(),
743                is_admin: true,
744                roles: Vec::new(),
745                permissions: Vec::new(),
746            })
747    })
748}
749
750/// `POST /sessions` — open a long-lived session for cross-request interactive
751/// transactions. Returns `{"session_id": "..."}`; send `X-Session-ID: <token>`
752/// on subsequent `/sql` requests to route to it. The session is owned by the
753/// authenticated principal and auto-expires after the idle timeout.
754async fn create_session(
755    State(state): State<Arc<AppState>>,
756    OptionalPrincipal(principal): OptionalPrincipal,
757) -> Response {
758    let owner = request_owner(&state, &principal);
759    let session = match MongrelSession::open_with_external_modules_as(
760        Arc::clone(&state.db),
761        state.external_modules.iter().cloned(),
762        request_principal(&state, &principal),
763    ) {
764        Ok(s) => s,
765        Err(e) => return (status_for_query_error(&e), e.to_string()).into_response(),
766    };
767    match state.sessions.create(session, owner.clone()) {
768        Some(token) => {
769            state.audit.record(owner, "session.open", "session created");
770            Json(json!({ "session_id": token })).into_response()
771        }
772        None => (
773            StatusCode::SERVICE_UNAVAILABLE,
774            "session limit reached; close an idle session or raise --max-sessions",
775        )
776            .into_response(),
777    }
778}
779
780/// `DELETE /sessions/{id}` — close a session, discarding any open (staged but
781/// uncommitted) transaction. Only the owning principal may close it.
782async fn close_session(
783    State(state): State<Arc<AppState>>,
784    OptionalPrincipal(principal): OptionalPrincipal,
785    Path(id): Path<String>,
786) -> Response {
787    let owner = request_owner(&state, &principal);
788    if state.sessions.close(&id, &owner) {
789        state.audit.record(owner, "session.close", "session closed");
790        StatusCode::OK.into_response()
791    } else {
792        (
793            StatusCode::NOT_FOUND,
794            "session not found or not owned by caller",
795        )
796            .into_response()
797    }
798}
799
800/// Choose a buffered response serialization: `"arrow"` (IPC file) or JSON.
801/// Streaming IPC is dispatched before query collection.
802fn dispatch_buffered_sql_format(
803    format: Option<&str>,
804    batches: &[arrow::record_batch::RecordBatch],
805) -> Response {
806    match format {
807        Some("arrow") => sql_arrow_response(batches),
808        _ => sql_json_response(batches),
809    }
810}
811
812/// Validate a prepared-statement name: bare identifier only
813/// (`[A-Za-z_][A-Za-z0-9_]*`). Prevents SQL injection via the name, which is
814/// interpolated into `PREPARE <name> AS ...` / `EXECUTE <name>(...)`.
815fn validate_stmt_name(name: &str) -> Result<(), String> {
816    let mut chars = name.chars();
817    match chars.next() {
818        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
819        _ => return Err("statement name must start with a letter or underscore".into()),
820    }
821    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
822        return Err("statement name may contain only letters, digits, or underscore".into());
823    }
824    Ok(())
825}
826
827/// Render a JSON value as a safe SQL literal for `EXECUTE` parameter binding.
828/// Values are escaped so a client cannot inject SQL through a parameter.
829/// Returns `Err` for non-scalar JSON (arrays/objects) so the caller rejects the
830/// request with 400 rather than silently binding NULL.
831fn render_sql_literal(v: &serde_json::Value) -> Result<String, String> {
832    match v {
833        serde_json::Value::Null => Ok("NULL".into()),
834        serde_json::Value::Bool(b) => {
835            if *b {
836                Ok("TRUE".into())
837            } else {
838                Ok("FALSE".into())
839            }
840        }
841        serde_json::Value::Number(n) => Ok(n.to_string()),
842        // Single-quote, doubling embedded single quotes (SQL-standard escape).
843        serde_json::Value::String(s) => {
844            let mut out = String::with_capacity(s.len() + 2);
845            out.push('\'');
846            for c in s.chars() {
847                if c == '\'' {
848                    out.push_str("''");
849                } else {
850                    out.push(c);
851                }
852            }
853            out.push('\'');
854            Ok(out)
855        }
856        // Arrays/objects are not valid scalar params; reject explicitly.
857        _ => Err("prepared-statement parameters must be scalar (null/bool/number/string)".into()),
858    }
859}
860
861#[derive(Deserialize)]
862struct PrepareRequest {
863    name: String,
864    sql: String,
865}
866
867/// `POST /sessions/{id}/prepare` — parse+plan `sql` once and store it under
868/// `name` on the session. Subsequent `EXECUTE name(...)` calls (via this
869/// endpoint or `EXECUTE` SQL) reuse the cached plan, skipping re-planning.
870async fn prepare_statement(
871    State(state): State<Arc<AppState>>,
872    OptionalPrincipal(principal): OptionalPrincipal,
873    Path(id): Path<String>,
874    Json(req): Json<PrepareRequest>,
875) -> Response {
876    if let Err(msg) = validate_stmt_name(&req.name) {
877        return (StatusCode::BAD_REQUEST, msg).into_response();
878    }
879    let owner = request_owner(&state, &principal);
880    let Some(entry) = state.sessions.get(&id, &owner) else {
881        return (
882            StatusCode::NOT_FOUND,
883            "session not found or not owned by caller",
884        )
885            .into_response();
886    };
887    let _guard = entry.lock.lock().await;
888    if entry.is_closed() {
889        return (StatusCode::NOT_FOUND, "session no longer available").into_response();
890    }
891    entry.touch();
892    let sql = format!("PREPARE {} AS {}", req.name, req.sql);
893    match entry.session.run(&sql).await {
894        Ok(_) => Json(json!({ "prepared": req.name })).into_response(),
895        Err(e) => (status_for_query_error(&e), e.to_string()).into_response(),
896    }
897}
898
899#[derive(Deserialize)]
900struct ExecuteRequest {
901    name: String,
902    params: Vec<serde_json::Value>,
903    #[serde(default)]
904    format: Option<String>,
905}
906
907/// `POST /sessions/{id}/execute` — run a previously-prepared statement with
908/// typed parameters, reusing its cached plan. Returns the same formats as
909/// `/sql` (`json` default, `arrow`, `arrow-stream`).
910async fn execute_statement(
911    State(state): State<Arc<AppState>>,
912    OptionalPrincipal(principal): OptionalPrincipal,
913    Path(id): Path<String>,
914    Json(req): Json<ExecuteRequest>,
915) -> Response {
916    if let Err(msg) = validate_stmt_name(&req.name) {
917        return (StatusCode::BAD_REQUEST, msg).into_response();
918    }
919    let owner = request_owner(&state, &principal);
920    let Some(entry) = state.sessions.get(&id, &owner) else {
921        return (
922            StatusCode::NOT_FOUND,
923            "session not found or not owned by caller",
924        )
925            .into_response();
926    };
927    let _guard = entry.lock.lock().await;
928    if entry.is_closed() {
929        return (StatusCode::NOT_FOUND, "session no longer available").into_response();
930    }
931    entry.touch();
932    state.metrics.inc_sql_queries();
933    let literals: Vec<String> = match req
934        .params
935        .iter()
936        .map(render_sql_literal)
937        .collect::<Result<_, _>>()
938    {
939        Ok(v) => v,
940        Err(msg) => {
941            state.metrics.inc_sql_errors();
942            return (StatusCode::BAD_REQUEST, msg).into_response();
943        }
944    };
945    let sql = format!("EXECUTE {}({})", req.name, literals.join(", "));
946    let start = std::time::Instant::now();
947    let result = if req.format.as_deref() == Some("arrow-stream") {
948        entry
949            .session
950            .run_stream(&sql)
951            .await
952            .map(sql_arrow_stream_response)
953    } else {
954        entry
955            .session
956            .run(&sql)
957            .await
958            .map(|batches| dispatch_buffered_sql_format(req.format.as_deref(), &batches))
959    };
960    let elapsed = start.elapsed();
961    if elapsed >= state.slow_query_threshold {
962        state.metrics.inc_slow_queries();
963        eprintln!(
964            "[slow-query] {}\u{00b5}s \u{2014} EXECUTE {}",
965            elapsed.as_micros(),
966            req.name
967        );
968    }
969    match result {
970        Ok(response) => response,
971        Err(e) => {
972            state.metrics.inc_sql_errors();
973            // A reference to an unprepared/unknown statement is a client error.
974            let msg = format!("{e}");
975            let status = if msg.contains("does not exist") {
976                StatusCode::NOT_FOUND
977            } else {
978                status_for_query_error(&e)
979            };
980            (status, format!("{msg} ({}µs)", elapsed.as_micros())).into_response()
981        }
982    }
983}
984
985/// `DELETE /sessions/{id}/statements/{name}` — drop a prepared statement from
986/// the session (SQL `DEALLOCATE`).
987async fn deallocate_statement(
988    State(state): State<Arc<AppState>>,
989    OptionalPrincipal(principal): OptionalPrincipal,
990    Path((id, name)): Path<(String, String)>,
991) -> Response {
992    if let Err(msg) = validate_stmt_name(&name) {
993        return (StatusCode::BAD_REQUEST, msg).into_response();
994    }
995    let owner = request_owner(&state, &principal);
996    let Some(entry) = state.sessions.get(&id, &owner) else {
997        return (
998            StatusCode::NOT_FOUND,
999            "session not found or not owned by caller",
1000        )
1001            .into_response();
1002    };
1003    let _guard = entry.lock.lock().await;
1004    if entry.is_closed() {
1005        return (StatusCode::NOT_FOUND, "session no longer available").into_response();
1006    }
1007    entry.touch();
1008    let sql = format!("DEALLOCATE {name}");
1009    match entry.session.run(&sql).await {
1010        Ok(_) => Json(json!({ "deallocated": name })).into_response(),
1011        Err(e) => (status_for_query_error(&e), e.to_string()).into_response(),
1012    }
1013}
1014
1015/// `mongreldb_tables` gauge. Subject to the same auth middleware as every other
1016/// route (scrape with the configured Bearer token / Basic credentials).
1017async fn metrics_handler(
1018    State(state): State<Arc<AppState>>,
1019    OptionalPrincipal(principal): OptionalPrincipal,
1020) -> Response {
1021    if let Err(error) = state.db.require_for(
1022        request_principal(&state, &principal).as_ref(),
1023        &mongreldb_core::Permission::Admin,
1024    ) {
1025        return (status_for_error(&error), error.to_string()).into_response();
1026    }
1027    let body = state.metrics.prometheus_text(state.db.table_names().len());
1028    (
1029        [(
1030            header::CONTENT_TYPE,
1031            "text/plain; version=0.0.4; charset=utf-8".to_string(),
1032        )],
1033        body,
1034    )
1035        .into_response()
1036}
1037
1038/// `POST /compact` — compact all mounted tables.
1039async fn compact_all(
1040    State(state): State<Arc<AppState>>,
1041    OptionalPrincipal(principal): OptionalPrincipal,
1042) -> (StatusCode, Json<serde_json::Value>) {
1043    if let Err(error) = state.db.require_for(
1044        request_principal(&state, &principal).as_ref(),
1045        &mongreldb_core::Permission::Ddl,
1046    ) {
1047        return (
1048            status_for_error(&error),
1049            Json(json!({ "status": "error", "message": error.to_string() })),
1050        );
1051    }
1052    match state.db.compact() {
1053        Ok((compacted, skipped)) => (
1054            StatusCode::OK,
1055            Json(json!({
1056                "status": "ok",
1057                "compacted": compacted,
1058                "skipped": skipped,
1059            })),
1060        ),
1061        Err(e) => (
1062            StatusCode::INTERNAL_SERVER_ERROR,
1063            Json(json!({ "status": "error", "message": format!("{e}") })),
1064        ),
1065    }
1066}
1067
1068/// `POST /tables/{name}/compact` — compact a single table.
1069async fn compact_table(
1070    State(state): State<Arc<AppState>>,
1071    OptionalPrincipal(principal): OptionalPrincipal,
1072    Path(name): Path<String>,
1073) -> (StatusCode, Json<serde_json::Value>) {
1074    if let Err(error) = state.db.require_for(
1075        request_principal(&state, &principal).as_ref(),
1076        &mongreldb_core::Permission::Ddl,
1077    ) {
1078        return (
1079            status_for_error(&error),
1080            Json(json!({ "status": "error", "table": name, "message": error.to_string() })),
1081        );
1082    }
1083    match state.db.compact_table(&name) {
1084        Ok(true) => (
1085            StatusCode::OK,
1086            Json(json!({ "status": "compacted", "table": name })),
1087        ),
1088        Ok(false) => (
1089            StatusCode::OK,
1090            Json(json!({ "status": "skipped", "table": name, "reason": "fewer than 2 runs" })),
1091        ),
1092        Err(e) => (
1093            StatusCode::INTERNAL_SERVER_ERROR,
1094            Json(json!({ "status": "error", "table": name, "message": format!("{e}") })),
1095        ),
1096    }
1097}
1098
1099#[derive(Deserialize)]
1100struct CreateTableRequest {
1101    name: String,
1102    columns: Vec<ColumnDefJson>,
1103}
1104
1105#[derive(Deserialize)]
1106struct ColumnDefJson {
1107    id: u16,
1108    name: String,
1109    ty: String,
1110    primary_key: bool,
1111    #[serde(default)]
1112    nullable: bool,
1113}
1114
1115async fn create_table(
1116    State(state): State<Arc<AppState>>,
1117    OptionalPrincipal(principal): OptionalPrincipal,
1118    Json(req): Json<CreateTableRequest>,
1119) -> Response {
1120    if let Err(error) = state.db.require_for(
1121        request_principal(&state, &principal).as_ref(),
1122        &mongreldb_core::Permission::Ddl,
1123    ) {
1124        return (status_for_error(&error), error.to_string()).into_response();
1125    }
1126    let mut columns = Vec::new();
1127    for c in &req.columns {
1128        let ty = match c.ty.as_str() {
1129            "int64" | "bigint" => TypeId::Int64,
1130            "float64" | "double" => TypeId::Float64,
1131            "bytes" | "varchar" | "text" => TypeId::Bytes,
1132            "bool" => TypeId::Bool,
1133            other => {
1134                return (StatusCode::BAD_REQUEST, format!("unknown type: {other}")).into_response()
1135            }
1136        };
1137        let mut flags = mongreldb_core::schema::ColumnFlags::empty();
1138        if c.primary_key {
1139            flags = flags.with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY);
1140        }
1141        if c.nullable {
1142            flags = flags.with(mongreldb_core::schema::ColumnFlags::NULLABLE);
1143        }
1144        columns.push(mongreldb_core::schema::ColumnDef {
1145            id: c.id,
1146            name: c.name.clone(),
1147            ty,
1148            flags,
1149            default_value: None,
1150        });
1151    }
1152    let schema = Schema {
1153        schema_id: 0,
1154        columns,
1155        indexes: vec![],
1156        colocation: vec![],
1157        constraints: Default::default(),
1158        clustered: false,
1159    };
1160    if let Err(msg) = validate_table_name(&req.name) {
1161        return (StatusCode::BAD_REQUEST, msg).into_response();
1162    }
1163    match state.db.create_table(&req.name, schema) {
1164        Ok(id) => Json(json!({ "table_id": id })).into_response(),
1165        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1166    }
1167}
1168
1169async fn list_tables(
1170    State(state): State<Arc<AppState>>,
1171    OptionalPrincipal(principal): OptionalPrincipal,
1172) -> Json<Vec<String>> {
1173    let principal = request_principal(&state, &principal);
1174    Json(
1175        state
1176            .db
1177            .table_names()
1178            .into_iter()
1179            .filter(|table| {
1180                state
1181                    .db
1182                    .select_column_ids_for(table, principal.as_ref())
1183                    .is_ok()
1184            })
1185            .collect(),
1186    )
1187}
1188
1189async fn drop_table(
1190    State(state): State<Arc<AppState>>,
1191    OptionalPrincipal(principal): OptionalPrincipal,
1192    Path(name): Path<String>,
1193) -> Response {
1194    if let Err(error) = state.db.require_for(
1195        request_principal(&state, &principal).as_ref(),
1196        &mongreldb_core::Permission::Ddl,
1197    ) {
1198        return (status_for_error(&error), error.to_string()).into_response();
1199    }
1200    match state.db.drop_table(&name) {
1201        Ok(_) => {
1202            // Invalidate cached idempotency entries. A cached transaction
1203            // may reference the dropped table; replaying it would silently
1204            // report success without writing to the recreated table.
1205            state.idem.clear();
1206            StatusCode::OK.into_response()
1207        }
1208        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1209    }
1210}
1211
1212#[derive(Deserialize)]
1213struct PutRequest {
1214    row: Vec<serde_json::Value>,
1215}
1216
1217pub(crate) fn json_to_value(v: &serde_json::Value, expected: &TypeId) -> Value {
1218    match (v, expected) {
1219        (serde_json::Value::Number(n), TypeId::Float64) => {
1220            n.as_f64().map(Value::Float64).unwrap_or(Value::Null)
1221        }
1222        (serde_json::Value::Number(n), TypeId::Int64) => {
1223            n.as_i64().map(Value::Int64).unwrap_or(Value::Null)
1224        }
1225        (serde_json::Value::String(s), TypeId::Bytes) => Value::Bytes(s.as_bytes().to_vec()),
1226        (serde_json::Value::String(s), TypeId::Enum { variants }) => {
1227            if variants.iter().any(|v| v == s) {
1228                Value::Bytes(s.as_bytes().to_vec())
1229            } else {
1230                Value::Null
1231            }
1232        }
1233        (serde_json::Value::Bool(b), TypeId::Bool) => Value::Bool(*b),
1234        // Embedding input: a JSON array of numbers, validated against the
1235        // declared dimension. Mismatched length or non-numeric elements → Null.
1236        (serde_json::Value::Array(arr), TypeId::Embedding { dim }) => {
1237            if arr.len() as u32 != *dim {
1238                return Value::Null;
1239            }
1240            let vec: Option<Vec<f32>> =
1241                arr.iter().map(|el| el.as_f64().map(|f| f as f32)).collect();
1242            vec.map(Value::Embedding).unwrap_or(Value::Null)
1243        }
1244        (serde_json::Value::Null, _) => Value::Null,
1245        // Lenient fallbacks for unknown/loosely-typed JSON.
1246        (serde_json::Value::Number(n), _) => {
1247            if let Some(i) = n.as_i64() {
1248                Value::Int64(i)
1249            } else if let Some(f) = n.as_f64() {
1250                Value::Float64(f)
1251            } else {
1252                Value::Null
1253            }
1254        }
1255        (serde_json::Value::String(s), _) => Value::Bytes(s.as_bytes().to_vec()),
1256        (serde_json::Value::Bool(b), _) => Value::Bool(*b),
1257        _ => Value::Null,
1258    }
1259}
1260
1261/// Parse a flat JSON array `[col_id, val, col_id, val, ...]` into typed cell
1262/// pairs, validating the schema. Returns `Err(message)` on any malformed pair.
1263fn parse_cells(
1264    row: &[serde_json::Value],
1265    schema: &mongreldb_core::schema::Schema,
1266) -> Result<Vec<(u16, Value)>, String> {
1267    if row.len() & 1 != 0 {
1268        return Err("row must be an even-length array of [col_id, value] pairs".into());
1269    }
1270    let mut out = Vec::with_capacity(row.len() / 2);
1271    for chunk in row.chunks(2) {
1272        let col_id = chunk[0]
1273            .as_u64()
1274            .ok_or("column id must be a non-negative integer")? as u16;
1275        let expected = schema
1276            .columns
1277            .iter()
1278            .find(|c| c.id == col_id)
1279            .map(|c| c.ty.clone())
1280            .ok_or_else(|| format!("unknown column id {col_id}"))?;
1281        let val = json_to_value(&chunk[1], &expected);
1282        out.push((col_id, val));
1283    }
1284    Ok(out)
1285}
1286
1287/// Basic validation for a table name: non-empty and no path separators.
1288pub(crate) fn validate_table_name(name: &str) -> Result<(), String> {
1289    if name.is_empty() {
1290        return Err("table name must not be empty".into());
1291    }
1292    if name.contains('/') || name.contains('\\') || name.contains('\0') {
1293        return Err("table name contains invalid characters".into());
1294    }
1295    Ok(())
1296}
1297
1298async fn put_row(
1299    State(state): State<Arc<AppState>>,
1300    OptionalPrincipal(principal): OptionalPrincipal,
1301    Path(name): Path<String>,
1302    Json(req): Json<PutRequest>,
1303) -> Response {
1304    let handle = match state.db.table(&name) {
1305        Ok(h) => h,
1306        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
1307    };
1308    let schema = handle.lock().schema().clone();
1309    let row = match parse_cells(&req.row, &schema) {
1310        Ok(r) => r,
1311        Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
1312    };
1313    state.metrics.inc_puts();
1314    let principal = request_principal(&state, &principal);
1315    match state.db.put_for(&name, row, principal.as_ref()) {
1316        Ok(rid) => Json(json!({ "row_id": rid.0.to_string() })).into_response(),
1317        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1318    }
1319}
1320
1321async fn count(
1322    State(state): State<Arc<AppState>>,
1323    OptionalPrincipal(principal): OptionalPrincipal,
1324    Path(name): Path<String>,
1325) -> Response {
1326    let principal = request_principal(&state, &principal);
1327    match state.db.count_for(&name, principal.as_ref()) {
1328        Ok(count) => Json(json!({ "count": count })).into_response(),
1329        Err(error) => (status_for_error(&error), error.to_string()).into_response(),
1330    }
1331}
1332
1333async fn commit(
1334    State(state): State<Arc<AppState>>,
1335    OptionalPrincipal(principal): OptionalPrincipal,
1336    Path(name): Path<String>,
1337) -> Response {
1338    if let Err(error) = state.db.require_for(
1339        request_principal(&state, &principal).as_ref(),
1340        &mongreldb_core::Permission::Update {
1341            table: name.clone(),
1342        },
1343    ) {
1344        return (status_for_error(&error), error.to_string()).into_response();
1345    }
1346    let handle = match state.db.table(&name) {
1347        Ok(h) => h,
1348        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
1349    };
1350    let mut g = handle.lock();
1351    state.metrics.inc_commits();
1352    match g.commit() {
1353        Ok(epoch) => Json(json!({ "epoch": epoch.0 })).into_response(),
1354        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1355    }
1356}
1357
1358#[derive(Deserialize)]
1359struct SqlRequest {
1360    sql: String,
1361    /// Output format: `"json"` (the default) for a JSON array of row objects,
1362    /// `"arrow"` for Arrow IPC file bytes.
1363    #[serde(default)]
1364    format: Option<String>,
1365}
1366
1367async fn sql(
1368    State(state): State<Arc<AppState>>,
1369    OptionalPrincipal(principal): OptionalPrincipal,
1370    headers: axum::http::HeaderMap,
1371    Json(req): Json<SqlRequest>,
1372) -> Response {
1373    // Session routing: an `X-Session-ID` header routes the request to a pooled
1374    // long-lived session, enabling cross-request `BEGIN`/`INSERT`/`COMMIT`
1375    // transactions. Without the header, a fresh ephemeral session is used
1376    // (the historical behavior).
1377    let session_id = headers
1378        .get("x-session-id")
1379        .and_then(|v| v.to_str().ok())
1380        .map(str::to_owned);
1381
1382    if let Some(sid) = session_id {
1383        let owner = request_owner(&state, &principal);
1384        let Some(entry) = state.sessions.get(&sid, &owner) else {
1385            return (
1386                StatusCode::NOT_FOUND,
1387                "session not found or not owned by caller",
1388            )
1389                .into_response();
1390        };
1391        // Serialize per-session access so two concurrent requests on the same
1392        // token cannot interleave a transaction's staged writes.
1393        let _guard = entry.lock.lock().await;
1394        // Re-check closed: the session may have been closed/evicted between
1395        // get() and acquiring the lock.
1396        if entry.is_closed() {
1397            return (StatusCode::NOT_FOUND, "session no longer available").into_response();
1398        }
1399        entry.touch();
1400        execute_sql(&state, &principal, &entry.session, req).await
1401    } else {
1402        let session = match MongrelSession::open_with_external_modules_as(
1403            Arc::clone(&state.db),
1404            state.external_modules.iter().cloned(),
1405            request_principal(&state, &principal),
1406        ) {
1407            Ok(s) => s,
1408            Err(e) => return (status_for_query_error(&e), e.to_string()).into_response(),
1409        };
1410        execute_sql(&state, &principal, &session, req).await
1411    }
1412}
1413
1414/// Run one SQL request against a given session: bump counters, audit DDL
1415/// (after execution, with redaction), log slow queries on both success and
1416/// failure, and dispatch the response format. Shared by the pooled-session and
1417/// fresh-session paths.
1418async fn execute_sql(
1419    state: &AppState,
1420    principal: &Option<mongreldb_core::Principal>,
1421    session: &MongrelSession,
1422    req: SqlRequest,
1423) -> Response {
1424    state.metrics.inc_sql_queries();
1425    let audited = audit::is_audited_sql(&req.sql);
1426    let actor = request_owner(state, principal);
1427    let start = std::time::Instant::now();
1428    // NOTE: deliberately NOT using `run_sql_traced` here. Its thread-local
1429    // push/pop spans an `.await`, and on a multi-threaded tokio runtime the
1430    // task can resume on a different thread, corrupting the trace stack and
1431    // leaking scopes. Wall-clock timing is sufficient for slow-query detection
1432    // and works across awaits.
1433    let result = if req.format.as_deref() == Some("arrow-stream") {
1434        session
1435            .run_stream(&req.sql)
1436            .await
1437            .map(sql_arrow_stream_response)
1438    } else {
1439        session
1440            .run(&req.sql)
1441            .await
1442            .map(|batches| dispatch_buffered_sql_format(req.format.as_deref(), &batches))
1443    };
1444    let elapsed = start.elapsed();
1445    // Slow-query logging covers BOTH success and failure (the slowest errors
1446    // matter most for diagnosis), checked before branching on the outcome.
1447    if elapsed >= state.slow_query_threshold {
1448        state.metrics.inc_slow_queries();
1449        let preview: String = req.sql.chars().take(80).collect();
1450        eprintln!(
1451            "[slow-query] {}\u{00b5}s \u{2014} {preview}",
1452            elapsed.as_micros()
1453        );
1454    }
1455    // Audit DDL/privilege AFTER execution so the outcome (ok/fail) is captured.
1456    // `redacted_ddl_detail` never logs credential literals.
1457    if audited {
1458        let (action, detail) = audit::redacted_ddl_detail(&req.sql, result.is_ok());
1459        state.audit.record(actor, action, detail);
1460    }
1461    match result {
1462        Ok(response) => response,
1463        Err(e) => {
1464            state.metrics.inc_sql_errors();
1465            (
1466                status_for_query_error(&e),
1467                format!("{e} ({}µs)", elapsed.as_micros()),
1468            )
1469                .into_response()
1470        }
1471    }
1472}
1473
1474/// Serialize Arrow record batches as Arrow IPC file bytes. This is the
1475/// high-performance binary format for clients with Arrow library support.
1476fn sql_arrow_response(batches: &[arrow::record_batch::RecordBatch]) -> Response {
1477    if batches.is_empty() {
1478        return StatusCode::OK.into_response();
1479    }
1480    let schema = batches[0].schema();
1481    let mut buf = Vec::new();
1482    let mut writer = arrow::ipc::writer::FileWriter::try_new(&mut buf, schema.as_ref()).unwrap();
1483    for b in batches {
1484        let _ = writer.write(b);
1485    }
1486    let _ = writer.finish();
1487    drop(writer);
1488    (
1489        [(header::CONTENT_TYPE, "application/vnd.apache.arrow.file")],
1490        buf,
1491    )
1492        .into_response()
1493}
1494
1495/// Serialize a DataFusion record-batch stream as Arrow streaming IPC. The body
1496/// holds only the active query batch and one serialized IPC message.
1497fn sql_arrow_stream_response(batches: mongreldb_query::MongrelRecordBatchStream) -> Response {
1498    use futures::{stream, StreamExt};
1499
1500    const STREAM_CT: &str = "application/vnd.apache.arrow.stream";
1501
1502    let schema = batches.schema();
1503    let mut writer = match arrow::ipc::writer::StreamWriter::try_new(Vec::new(), schema.as_ref()) {
1504        Ok(w) => w,
1505        Err(e) => {
1506            return (
1507                StatusCode::INTERNAL_SERVER_ERROR,
1508                format!("arrow stream init error: {e}"),
1509            )
1510                .into_response()
1511        }
1512    };
1513    // `try_new` synchronously writes the schema message; drain it so it becomes
1514    // the first chunk yielded to the client.
1515    let schema_chunk: Vec<u8> = std::mem::take(writer.get_mut());
1516    let batch_stream = stream::unfold(
1517        (batches, Some(writer)),
1518        |(mut batches, writer)| async move {
1519            let mut writer = writer?;
1520            match batches.next().await {
1521                Some(Ok(batch)) => match writer.write(&batch) {
1522                    Ok(()) => {
1523                        let chunk = std::mem::take(writer.get_mut());
1524                        Some((Ok(chunk), (batches, Some(writer))))
1525                    }
1526                    Err(error) => Some((Err(std::io::Error::other(error)), (batches, None))),
1527                },
1528                Some(Err(error)) => Some((Err(std::io::Error::other(error)), (batches, None))),
1529                None => match writer.finish() {
1530                    Ok(()) => {
1531                        let chunk = std::mem::take(writer.get_mut());
1532                        Some((Ok(chunk), (batches, None)))
1533                    }
1534                    Err(error) => Some((Err(std::io::Error::other(error)), (batches, None))),
1535                },
1536            }
1537        },
1538    );
1539
1540    // Schema first, then batches + EOS. Each item is already `Result<Vec<u8>,
1541    // io::Error>` so a mid-stream encode failure surfaces as a body error.
1542    let schema_item: Result<Vec<u8>, std::io::Error> = Ok(schema_chunk);
1543    let full = stream::iter([schema_item]).chain(batch_stream);
1544    let body = axum::body::Body::from_stream(full);
1545    ([(header::CONTENT_TYPE, STREAM_CT)], body).into_response()
1546}
1547
1548/// Serialize Arrow record batches into a JSON array of row objects using the
1549/// Arrow JSON writer. Each row becomes a JSON object keyed by column name.
1550/// This handles all Arrow types correctly (Decimal128, Timestamp, Date32, List,
1551/// etc.) and streams directly into a byte buffer without intermediate
1552/// serde_json::Value allocations.
1553fn sql_json_response(batches: &[arrow::record_batch::RecordBatch]) -> Response {
1554    if batches.is_empty() {
1555        return ([(header::CONTENT_TYPE, "application/json")], b"[]" as &[u8]).into_response();
1556    }
1557
1558    let mut buf = Vec::new();
1559    {
1560        let mut writer = arrow::json::writer::ArrayWriter::new(&mut buf);
1561        let refs: Vec<&_> = batches.iter().collect();
1562        if let Err(e) = writer.write_batches(&refs) {
1563            return (
1564                StatusCode::INTERNAL_SERVER_ERROR,
1565                format!("JSON serialization error: {e}"),
1566            )
1567                .into_response();
1568        }
1569        let _ = writer.finish();
1570    }
1571
1572    ([(header::CONTENT_TYPE, "application/json")], buf).into_response()
1573}
1574
1575#[derive(Deserialize)]
1576struct TxnOp {
1577    table: String,
1578    op: String,
1579    cells: Option<Vec<serde_json::Value>>,
1580    row_id: Option<u64>,
1581}
1582
1583#[derive(Deserialize)]
1584struct TxnRequest {
1585    ops: Vec<TxnOp>,
1586}
1587
1588async fn txn(
1589    State(state): State<Arc<AppState>>,
1590    OptionalPrincipal(principal): OptionalPrincipal,
1591    Json(req): Json<TxnRequest>,
1592) -> Response {
1593    // Pre-validate every op against the live schemas before entering the
1594    // transaction, so malformed input returns 400 without consuming an epoch
1595    // or poisoning a txn.
1596    let mut parsed: Vec<(String, TxnAction)> = Vec::with_capacity(req.ops.len());
1597    for op in &req.ops {
1598        match op.op.as_str() {
1599            "put" => {
1600                let cells_json = match op.cells.as_ref() {
1601                    Some(c) if !c.is_empty() => c,
1602                    _ => {
1603                        return (StatusCode::BAD_REQUEST, "put op requires non-empty cells")
1604                            .into_response()
1605                    }
1606                };
1607                let handle = match state.db.table(&op.table) {
1608                    Ok(h) => h,
1609                    Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
1610                };
1611                let schema = handle.lock().schema().clone();
1612                let cells = match parse_cells(cells_json, &schema) {
1613                    Ok(c) => c,
1614                    Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
1615                };
1616                parsed.push((op.table.clone(), TxnAction::Put(cells)));
1617            }
1618            "delete" => {
1619                let rid = match op.row_id {
1620                    Some(r) => r,
1621                    None => {
1622                        return (StatusCode::BAD_REQUEST, "delete op requires row_id")
1623                            .into_response()
1624                    }
1625                };
1626                parsed.push((op.table.clone(), TxnAction::Delete(rid)));
1627            }
1628            other => {
1629                return (StatusCode::BAD_REQUEST, format!("unknown op: {other}")).into_response()
1630            }
1631        }
1632    }
1633
1634    state.metrics.inc_txns();
1635    let mut transaction = state.db.begin_as(request_principal(&state, &principal));
1636    let result = (|| {
1637        for (table, action) in &parsed {
1638            match action {
1639                TxnAction::Put(cells) => {
1640                    transaction.put(table, cells.clone())?;
1641                }
1642                TxnAction::Delete(rid) => {
1643                    transaction.delete(table, mongreldb_core::RowId(*rid))?;
1644                }
1645            }
1646        }
1647        transaction.commit()
1648    })();
1649    match result {
1650        Ok(_) => Json(json!({ "status": "committed" })).into_response(),
1651        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1652    }
1653}
1654
1655enum TxnAction {
1656    Put(Vec<(u16, Value)>),
1657    Delete(u64),
1658}
1659
1660#[cfg(test)]
1661mod auth_tests {
1662    use super::*;
1663    use mongreldb_core::Database;
1664    use tempfile::tempdir;
1665
1666    #[tokio::test]
1667    async fn auth_rejects_missing_token() {
1668        let dir = tempdir().unwrap();
1669        let db = Arc::new(Database::create(dir.path()).unwrap());
1670        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
1671        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1672        let addr = listener.local_addr().unwrap();
1673        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1674        // Give the server a moment to start.
1675        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1676
1677        let client = reqwest::Client::new();
1678        let resp = client
1679            .get(format!("http://{addr}/health"))
1680            .send()
1681            .await
1682            .unwrap();
1683        assert_eq!(resp.status(), 401);
1684    }
1685
1686    #[tokio::test]
1687    async fn auth_accepts_valid_token() {
1688        let dir = tempdir().unwrap();
1689        let db = Arc::new(Database::create(dir.path()).unwrap());
1690        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
1691        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1692        let addr = listener.local_addr().unwrap();
1693        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1694        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1695
1696        let client = reqwest::Client::new();
1697        let resp = client
1698            .get(format!("http://{addr}/health"))
1699            .header("Authorization", "Bearer secret")
1700            .send()
1701            .await
1702            .unwrap();
1703        assert_eq!(resp.status(), 200);
1704    }
1705
1706    #[tokio::test]
1707    async fn no_auth_when_token_unset() {
1708        let dir = tempdir().unwrap();
1709        let db = Arc::new(Database::create(dir.path()).unwrap());
1710        let app = build_app_with_config(db, std::iter::empty(), None, None);
1711        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1712        let addr = listener.local_addr().unwrap();
1713        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1714        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1715
1716        let client = reqwest::Client::new();
1717        let resp = client
1718            .get(format!("http://{addr}/health"))
1719            .send()
1720            .await
1721            .unwrap();
1722        assert_eq!(resp.status(), 200);
1723    }
1724}
1725
1726#[cfg(test)]
1727mod wal_stream_tests {
1728    use super::*;
1729    use mongreldb_client::ReplicationFollower;
1730    use mongreldb_core::Database;
1731    use tempfile::tempdir;
1732
1733    #[tokio::test]
1734    async fn wal_stream_returns_records_after_commit() {
1735        let dir = tempdir().unwrap();
1736        let db = Arc::new(Database::create(dir.path()).unwrap());
1737        let table_schema = mongreldb_core::schema::Schema {
1738            schema_id: 1,
1739            columns: vec![mongreldb_core::schema::ColumnDef {
1740                id: 1,
1741                name: "id".into(),
1742                ty: TypeId::Int64,
1743                flags: mongreldb_core::schema::ColumnFlags::empty()
1744                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
1745                default_value: None,
1746            }],
1747            indexes: vec![],
1748            colocation: vec![],
1749            constraints: Default::default(),
1750            clustered: false,
1751        };
1752        db.create_table("items", table_schema).unwrap();
1753        // Write a row to generate WAL records.
1754        let handle = db.table("items").unwrap();
1755        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
1756        handle.lock().flush().unwrap();
1757
1758        let app = build_app(db);
1759        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1760        let addr = listener.local_addr().unwrap();
1761        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1762        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1763
1764        let resp = reqwest::get(format!("http://{addr}/wal/stream"))
1765            .await
1766            .unwrap();
1767        assert_eq!(resp.status(), 200);
1768        let body = resp.text().await.unwrap();
1769        // Should contain at least one record (the flush commit).
1770        assert!(!body.is_empty(), "wal_stream should return records");
1771        assert!(body.contains("seq"), "response should contain seq field");
1772    }
1773
1774    #[tokio::test]
1775    async fn follower_bootstraps_and_applies_incremental_commit() {
1776        let leader_dir = tempdir().unwrap();
1777        let follower_dir = tempdir().unwrap();
1778        let follower_path = follower_dir.path().join("copy");
1779        let db = Arc::new(Database::create(leader_dir.path()).unwrap());
1780        db.create_table(
1781            "items",
1782            mongreldb_core::schema::Schema {
1783                schema_id: 1,
1784                columns: vec![mongreldb_core::schema::ColumnDef {
1785                    id: 1,
1786                    name: "id".into(),
1787                    ty: TypeId::Int64,
1788                    flags: mongreldb_core::schema::ColumnFlags::empty()
1789                        .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
1790                    default_value: None,
1791                }],
1792                indexes: vec![],
1793                colocation: vec![],
1794                constraints: Default::default(),
1795                clustered: false,
1796            },
1797        )
1798        .unwrap();
1799        let handle = db.table("items").unwrap();
1800        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
1801        handle.lock().commit().unwrap();
1802
1803        let app = build_app_with_config(
1804            Arc::clone(&db),
1805            std::iter::empty::<Arc<dyn ExternalTableModule>>(),
1806            Some("replication-secret".into()),
1807            None,
1808        );
1809        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1810        let addr = listener.local_addr().unwrap();
1811        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1812        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1813
1814        let leader_url = format!("http://{addr}");
1815        let first_path = follower_path.clone();
1816        let (mut follower, initial) = tokio::task::spawn_blocking(move || {
1817            let mut follower = ReplicationFollower::new(&leader_url, first_path)
1818                .with_bearer_token("replication-secret");
1819            let applied = follower.sync().unwrap();
1820            (follower, applied)
1821        })
1822        .await
1823        .unwrap();
1824        assert_eq!(initial, 0);
1825
1826        handle.lock().put(vec![(1, Value::Int64(2))]).unwrap();
1827        handle.lock().commit().unwrap();
1828        let applied = tokio::task::spawn_blocking(move || {
1829            let count = follower.sync().unwrap();
1830            (follower, count)
1831        })
1832        .await
1833        .unwrap();
1834        follower = applied.0;
1835        assert!(applied.1 > 0);
1836        assert!(follower.last_epoch() > 0);
1837
1838        let replica = Database::open(&follower_path).unwrap();
1839        assert_eq!(replica.table("items").unwrap().lock().count(), 2);
1840        drop(replica);
1841
1842        db.set_spill_threshold(1);
1843        db.transaction(|txn| {
1844            txn.put("items", vec![(1, Value::Int64(3))])?;
1845            Ok(())
1846        })
1847        .unwrap();
1848        let (follower_after_bootstrap, applied) = tokio::task::spawn_blocking(move || {
1849            let count = follower.sync().unwrap();
1850            (follower, count)
1851        })
1852        .await
1853        .unwrap();
1854        assert_eq!(applied, 0, "spilled run should trigger safe rebootstrap");
1855        assert!(follower_after_bootstrap.last_epoch() > 0);
1856        let replica = Database::open(&follower_path).unwrap();
1857        assert_eq!(replica.table("items").unwrap().lock().count(), 3);
1858    }
1859}
1860
1861#[cfg(test)]
1862mod metrics_tests {
1863    use super::*;
1864    use mongreldb_core::Database;
1865    use tempfile::tempdir;
1866
1867    /// Helper: spin up a daemon over a fresh DB with one `items(id int64 pk)`
1868    /// table pre-created, returning the bound address.
1869    async fn setup() -> std::net::SocketAddr {
1870        let dir = tempdir().unwrap();
1871        let db = Arc::new(Database::create(dir.path()).unwrap());
1872        let table_schema = mongreldb_core::schema::Schema {
1873            schema_id: 1,
1874            columns: vec![mongreldb_core::schema::ColumnDef {
1875                id: 1,
1876                name: "id".into(),
1877                ty: TypeId::Int64,
1878                flags: mongreldb_core::schema::ColumnFlags::empty()
1879                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
1880                default_value: None,
1881            }],
1882            indexes: vec![],
1883            colocation: vec![],
1884            constraints: Default::default(),
1885            clustered: false,
1886        };
1887        db.create_table("items", table_schema).unwrap();
1888        let app = build_app(db);
1889        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1890        let addr = listener.local_addr().unwrap();
1891        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1892        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1893        addr
1894    }
1895
1896    #[tokio::test]
1897    async fn metrics_endpoint_returns_prometheus_text() {
1898        let addr = setup().await;
1899        let client = reqwest::Client::new();
1900
1901        // Exercise a few handlers to bump counters.
1902        let _ = client
1903            .post(format!("http://{addr}/tables/items/put"))
1904            .json(&json!({ "row": [1, 1] }))
1905            .send()
1906            .await
1907            .unwrap();
1908        let _ = client
1909            .post(format!("http://{addr}/sql"))
1910            .json(&json!({ "sql": "SELECT count(*) FROM items" }))
1911            .send()
1912            .await
1913            .unwrap();
1914
1915        let resp = client
1916            .get(format!("http://{addr}/metrics"))
1917            .send()
1918            .await
1919            .unwrap();
1920        assert_eq!(resp.status(), 200);
1921        let ct = resp
1922            .headers()
1923            .get("content-type")
1924            .and_then(|v| v.to_str().ok())
1925            .unwrap_or_default()
1926            .to_string();
1927        assert!(
1928            ct.contains("text/plain"),
1929            "content-type is prometheus text: {ct}"
1930        );
1931        let body = resp.text().await.unwrap();
1932        // Prometheus series + type lines are present.
1933        assert!(body.contains("# TYPE mongreldb_sql_queries_total counter"));
1934        assert!(body.contains("# TYPE mongreldb_puts_total counter"));
1935        assert!(body.contains("# TYPE mongreldb_tables gauge"));
1936        // Counters were bumped: at least one query and one put were served.
1937        assert!(
1938            body.contains("mongreldb_sql_queries_total 1"),
1939            "sql_queries counter should reflect the /sql call: {body}"
1940        );
1941        assert!(
1942            body.contains("mongreldb_puts_total 1"),
1943            "puts counter should reflect the put call: {body}"
1944        );
1945        // The tables gauge reflects the single `items` table.
1946        assert!(body.contains("mongreldb_tables 1"));
1947    }
1948
1949    #[tokio::test]
1950    async fn metrics_error_counter_increments_on_bad_sql() {
1951        let addr = setup().await;
1952        let client = reqwest::Client::new();
1953        // A query against a non-existent table errors at the engine layer.
1954        let _ = client
1955            .post(format!("http://{addr}/sql"))
1956            .json(&json!({ "sql": "SELECT * FROM does_not_exist" }))
1957            .send()
1958            .await
1959            .unwrap();
1960        let body = client
1961            .get(format!("http://{addr}/metrics"))
1962            .send()
1963            .await
1964            .unwrap()
1965            .text()
1966            .await
1967            .unwrap();
1968        assert!(
1969            body.contains("mongreldb_sql_errors_total 1"),
1970            "sql_errors should increment on a failed query: {body}"
1971        );
1972    }
1973
1974    #[tokio::test]
1975    async fn arrow_stream_returns_ipc_stream_bytes() {
1976        let addr = setup().await;
1977        let client = reqwest::Client::new();
1978        // Insert a couple of rows so there are real batches to stream, then
1979        // flush so the rows are durable/visible to a fresh SQL session.
1980        for i in 1..=3 {
1981            let resp = client
1982                .post(format!("http://{addr}/tables/items/put"))
1983                .json(&json!({ "row": [1, i] }))
1984                .send()
1985                .await
1986                .unwrap();
1987            assert_eq!(resp.status(), 200, "put should succeed");
1988        }
1989        let _ = client
1990            .post(format!("http://{addr}/tables/items/commit"))
1991            .send()
1992            .await
1993            .unwrap();
1994        // Sanity: the rows are durable and visible to the table handle.
1995        let count_body = client
1996            .get(format!("http://{addr}/tables/items/count"))
1997            .send()
1998            .await
1999            .unwrap()
2000            .text()
2001            .await
2002            .unwrap();
2003        assert!(
2004            count_body.contains("\"count\":3"),
2005            "expected 3 visible rows, got: {count_body}"
2006        );
2007        let resp = client
2008            .post(format!("http://{addr}/sql"))
2009            .json(&json!({ "sql": "SELECT count(*) FROM items", "format": "arrow-stream" }))
2010            .send()
2011            .await
2012            .unwrap();
2013        assert_eq!(resp.status(), 200, "streaming query should succeed");
2014        let ct = resp
2015            .headers()
2016            .get("content-type")
2017            .and_then(|v| v.to_str().ok())
2018            .unwrap_or_default()
2019            .to_string();
2020        assert!(
2021            ct.contains("application/vnd.apache.arrow.stream"),
2022            "content-type should be the arrow stream format: {ct}"
2023        );
2024        let bytes = resp.bytes().await.unwrap();
2025        // Arrow IPC streams begin with the magic continuation marker
2026        // 0xFFFFFFFF followed by the schema message length. The stream must be
2027        // non-empty (3 rows were written) and end with the EOS marker.
2028        assert!(
2029            !bytes.is_empty(),
2030            "arrow stream body should contain schema + batch + EOS"
2031        );
2032        assert!(
2033            bytes.starts_with(&0xFFFFFFFFu32.to_le_bytes()),
2034            "arrow stream must begin with the IPC continuation marker"
2035        );
2036        assert!(
2037            bytes.ends_with(&[0u8, 0, 0, 0]),
2038            "arrow stream should end with the EOS marker (trailing zero length)"
2039        );
2040    }
2041}
2042
2043#[cfg(test)]
2044mod streaming_tests {
2045    use super::*;
2046    use arrow::array::Int64Array;
2047    use arrow::datatypes::{DataType, Field, Schema};
2048    use arrow::record_batch::RecordBatch;
2049    use datafusion::common::DataFusionError;
2050    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
2051    use futures::StreamExt;
2052    use std::sync::Arc as StdArc;
2053
2054    fn batch_stream(batches: Vec<RecordBatch>) -> mongreldb_query::MongrelRecordBatchStream {
2055        let schema = batches
2056            .first()
2057            .map(RecordBatch::schema)
2058            .unwrap_or_else(|| StdArc::new(Schema::empty()));
2059        let batches =
2060            futures::stream::iter(batches.into_iter().map(Ok::<RecordBatch, DataFusionError>));
2061        Box::pin(RecordBatchStreamAdapter::new(schema, batches))
2062    }
2063
2064    /// Unit-level check: feed two synthetic batches through the streaming
2065    /// serializer and re-parse the resulting IPC stream end-to-end. This
2066    /// validates the per-message chunking (schema + N batches + EOS) without
2067    /// depending on the engine's scan visibility.
2068    #[tokio::test]
2069    async fn arrow_stream_serializes_multiple_batches_roundtrip() {
2070        let schema = StdArc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
2071        let b1 = RecordBatch::try_new(
2072            schema.clone(),
2073            vec![StdArc::new(Int64Array::from(vec![1, 2]))],
2074        )
2075        .unwrap();
2076        let b2 = RecordBatch::try_new(
2077            schema.clone(),
2078            vec![StdArc::new(Int64Array::from(vec![3, 4, 5]))],
2079        )
2080        .unwrap();
2081
2082        let resp = sql_arrow_stream_response(batch_stream(vec![b1, b2]));
2083        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
2084            .await
2085            .unwrap();
2086
2087        // Begins with the IPC continuation marker.
2088        assert!(bytes.starts_with(&0xFFFFFFFFu32.to_le_bytes()));
2089
2090        // Re-parse the full stream and confirm all rows survived the chunked
2091        // serialization.
2092        let slice: &[u8] = bytes.as_ref();
2093        let mut reader = arrow::ipc::reader::StreamReader::try_new(slice, None).unwrap();
2094        let mut total_rows = 0;
2095        for batch in reader.by_ref() {
2096            let batch = batch.expect("each IPC message should decode");
2097            total_rows += batch.num_rows();
2098        }
2099        assert_eq!(
2100            total_rows, 5,
2101            "all rows should round-trip through the stream"
2102        );
2103    }
2104
2105    #[tokio::test]
2106    async fn arrow_stream_emits_schema_before_first_batch() {
2107        let schema = StdArc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
2108        let pending = futures::stream::pending::<Result<RecordBatch, DataFusionError>>();
2109        let batches = Box::pin(RecordBatchStreamAdapter::new(schema, pending));
2110        let mut body = sql_arrow_stream_response(batches)
2111            .into_body()
2112            .into_data_stream();
2113
2114        let chunk = tokio::time::timeout(std::time::Duration::from_millis(100), body.next())
2115            .await
2116            .expect("schema chunk should not wait for a query batch")
2117            .unwrap()
2118            .unwrap();
2119        assert!(chunk.starts_with(&0xFFFFFFFFu32.to_le_bytes()));
2120    }
2121
2122    #[tokio::test]
2123    async fn arrow_stream_empty_query_is_valid_ipc() {
2124        let resp = sql_arrow_stream_response(batch_stream(Vec::new()));
2125        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
2126            .await
2127            .unwrap();
2128        let slice: &[u8] = bytes.as_ref();
2129        let reader = arrow::ipc::reader::StreamReader::try_new(slice, None).unwrap();
2130        assert_eq!(reader.count(), 0);
2131    }
2132}
2133
2134#[cfg(test)]
2135mod audit_tests {
2136    use super::*;
2137    use mongreldb_core::Database;
2138    use tempfile::tempdir;
2139
2140    /// Build a daemon with user-auth enabled plus one catalog user `alice`.
2141    async fn auth_setup(password: &str) -> std::net::SocketAddr {
2142        let dir = tempdir().unwrap();
2143        let db = Arc::new(Database::create(dir.path()).unwrap());
2144        db.create_user("alice", password).unwrap();
2145        db.set_user_admin("alice", true).unwrap();
2146        let app = build_app_full(
2147            db,
2148            std::iter::empty::<Arc<dyn ExternalTableModule>>(),
2149            None,
2150            None,
2151            true,
2152        );
2153        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2154        let addr = listener.local_addr().unwrap();
2155        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2156        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2157        addr
2158    }
2159
2160    #[tokio::test]
2161    async fn audit_records_login_success_and_failure() {
2162        let addr = auth_setup("s3cret").await;
2163        let client = reqwest::Client::new();
2164
2165        // Successful basic auth.
2166        let resp = client
2167            .get(format!("http://{addr}/health"))
2168            .header("Authorization", basic("alice", "s3cret"))
2169            .send()
2170            .await
2171            .unwrap();
2172        assert_eq!(resp.status(), 200);
2173
2174        // Failed basic auth (wrong password).
2175        let resp = client
2176            .get(format!("http://{addr}/health"))
2177            .header("Authorization", basic("alice", "wrong"))
2178            .send()
2179            .await
2180            .unwrap();
2181        assert_eq!(resp.status(), 401);
2182
2183        let body = client
2184            .get(format!("http://{addr}/audit"))
2185            .header("Authorization", basic("alice", "s3cret"))
2186            .send()
2187            .await
2188            .unwrap()
2189            .text()
2190            .await
2191            .unwrap();
2192        assert!(
2193            body.contains("\"action\":\"login.ok\""),
2194            "audit should record the successful login: {body}"
2195        );
2196        assert!(
2197            body.contains("\"action\":\"login.fail\""),
2198            "audit should record the failed login: {body}"
2199        );
2200        assert!(
2201            body.contains("\"principal\":\"alice\""),
2202            "audit should attribute events to alice: {body}"
2203        );
2204    }
2205
2206    #[tokio::test]
2207    async fn audit_records_ddl_sql() {
2208        let dir = tempdir().unwrap();
2209        let db = Arc::new(Database::create(dir.path()).unwrap());
2210        let app = build_app(db);
2211        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2212        let addr = listener.local_addr().unwrap();
2213        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2214        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2215
2216        let client = reqwest::Client::new();
2217        let _ = client
2218            .post(format!("http://{addr}/sql"))
2219            .json(&json!({ "sql": "CREATE TABLE t (id BIGINT PRIMARY KEY)" }))
2220            .send()
2221            .await
2222            .unwrap();
2223        // A plain SELECT is NOT audited.
2224        let _ = client
2225            .post(format!("http://{addr}/sql"))
2226            .json(&json!({ "sql": "SELECT 1" }))
2227            .send()
2228            .await
2229            .unwrap();
2230
2231        let body = client
2232            .get(format!("http://{addr}/audit"))
2233            .send()
2234            .await
2235            .unwrap()
2236            .text()
2237            .await
2238            .unwrap();
2239        assert!(
2240            body.contains("\"action\":\"ddl.ok\""),
2241            "audit should record the successful DDL statement: {body}"
2242        );
2243        assert!(
2244            body.contains("CREATE TABLE"),
2245            "audit detail should carry the DDL snippet: {body}"
2246        );
2247        // SELECT must not appear.
2248        assert!(
2249            !body.contains("SELECT 1"),
2250            "non-DDL reads should not be audited: {body}"
2251        );
2252    }
2253
2254    #[tokio::test]
2255    async fn audit_redacts_credential_passwords() {
2256        let dir = tempdir().unwrap();
2257        let db = Arc::new(Database::create(dir.path()).unwrap());
2258        let app = build_app(db);
2259        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2260        let addr = listener.local_addr().unwrap();
2261        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2262        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2263
2264        let client = reqwest::Client::new();
2265        // A CREATE USER carrying a plaintext password must be audited but the
2266        // password must NEVER reach /audit or stderr.
2267        let _ = client
2268            .post(format!("http://{addr}/sql"))
2269            .json(&json!({ "sql": "CREATE USER alice WITH PASSWORD 'topsecret'" }))
2270            .send()
2271            .await
2272            .unwrap();
2273
2274        let body = client
2275            .get(format!("http://{addr}/audit"))
2276            .send()
2277            .await
2278            .unwrap()
2279            .text()
2280            .await
2281            .unwrap();
2282        assert!(
2283            !body.contains("topsecret"),
2284            "password must never appear in the audit log: {body}"
2285        );
2286        assert!(
2287            body.contains("redacted credential statement"),
2288            "credential DDL should be recorded as redacted: {body}"
2289        );
2290    }
2291
2292    fn basic(user: &str, pass: &str) -> String {
2293        let raw = format!("{user}:{pass}");
2294        format!("Basic {}", base64_encode(raw.as_bytes()))
2295    }
2296
2297    fn base64_encode(input: &[u8]) -> String {
2298        const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2299        let mut out = String::new();
2300        let mut buf = 0u32;
2301        let mut bits = 0u32;
2302        for &b in input {
2303            buf = (buf << 8) | b as u32;
2304            bits += 8;
2305            while bits >= 6 {
2306                bits -= 6;
2307                out.push(TABLE[((buf >> bits) & 0x3F) as usize] as char);
2308            }
2309        }
2310        if bits > 0 {
2311            out.push(TABLE[((buf << (6 - bits)) & 0x3F) as usize] as char);
2312        }
2313        while !out.len().is_multiple_of(4) {
2314            out.push('=');
2315        }
2316        out
2317    }
2318}
2319
2320#[cfg(test)]
2321mod session_tests {
2322    use super::*;
2323    use mongreldb_core::Database;
2324    use tempfile::tempdir;
2325
2326    /// Spin up a daemon over a fresh DB with one `items(id int64 pk)` table.
2327    /// Returns the TempDir (must be held alive for the test's duration — dropping
2328    /// it deletes the database directory mid-test) and the bound address.
2329    async fn setup() -> (tempfile::TempDir, std::net::SocketAddr) {
2330        let dir = tempdir().unwrap();
2331        let db = Arc::new(Database::create(dir.path()).unwrap());
2332        let table_schema = mongreldb_core::schema::Schema {
2333            schema_id: 1,
2334            columns: vec![mongreldb_core::schema::ColumnDef {
2335                id: 1,
2336                name: "id".into(),
2337                ty: TypeId::Int64,
2338                flags: mongreldb_core::schema::ColumnFlags::empty()
2339                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
2340                default_value: None,
2341            }],
2342            indexes: vec![],
2343            colocation: vec![],
2344            constraints: Default::default(),
2345            clustered: false,
2346        };
2347        db.create_table("items", table_schema).unwrap();
2348        let app = build_app(db);
2349        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2350        let addr = listener.local_addr().unwrap();
2351        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2352        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2353        (dir, addr)
2354    }
2355
2356    /// Open a session and return its token.
2357    async fn open_session(client: &reqwest::Client, addr: &std::net::SocketAddr) -> String {
2358        let resp = client
2359            .post(format!("http://{addr}/sessions"))
2360            .send()
2361            .await
2362            .unwrap();
2363        assert_eq!(resp.status(), 200);
2364        resp.json::<serde_json::Value>()
2365            .await
2366            .unwrap()
2367            .get("session_id")
2368            .unwrap()
2369            .as_str()
2370            .unwrap()
2371            .to_string()
2372    }
2373
2374    async fn sql_on(
2375        client: &reqwest::Client,
2376        addr: &std::net::SocketAddr,
2377        session: &str,
2378        sql: &str,
2379    ) -> reqwest::Response {
2380        client
2381            .post(format!("http://{addr}/sql"))
2382            .header("X-Session-ID", session)
2383            .json(&json!({ "sql": sql }))
2384            .send()
2385            .await
2386            .unwrap()
2387    }
2388
2389    async fn count_items(client: &reqwest::Client, addr: &std::net::SocketAddr) -> u64 {
2390        client
2391            .get(format!("http://{addr}/tables/items/count"))
2392            .send()
2393            .await
2394            .unwrap()
2395            .json::<serde_json::Value>()
2396            .await
2397            .unwrap()
2398            .get("count")
2399            .unwrap()
2400            .as_u64()
2401            .unwrap()
2402    }
2403
2404    #[tokio::test]
2405    async fn cross_request_transaction_commits() {
2406        let (_dir, addr) = setup().await;
2407        let client = reqwest::Client::new();
2408        let session = open_session(&client, &addr).await;
2409
2410        // BEGIN, INSERT, COMMIT — each its own HTTP request on the same session.
2411        let r = sql_on(&client, &addr, &session, "BEGIN").await;
2412        assert_eq!(r.status(), 200);
2413        let r = sql_on(
2414            &client,
2415            &addr,
2416            &session,
2417            "INSERT INTO items (id) VALUES (1)",
2418        )
2419        .await;
2420        assert_eq!(r.status(), 200, "INSERT should stage successfully");
2421        // Not yet committed → not visible to other connections.
2422        assert_eq!(count_items(&client, &addr).await, 0);
2423        let r = sql_on(&client, &addr, &session, "COMMIT").await;
2424        assert_eq!(r.status(), 200);
2425
2426        // After COMMIT the row is durable and visible.
2427        assert_eq!(count_items(&client, &addr).await, 1);
2428    }
2429
2430    #[tokio::test]
2431    async fn cross_request_transaction_rolls_back() {
2432        let (_dir, addr) = setup().await;
2433        let client = reqwest::Client::new();
2434        let session = open_session(&client, &addr).await;
2435
2436        sql_on(&client, &addr, &session, "BEGIN").await;
2437        sql_on(
2438            &client,
2439            &addr,
2440            &session,
2441            "INSERT INTO items (id) VALUES (5)",
2442        )
2443        .await;
2444        // ROLLBACK discards the staged insert.
2445        let r = sql_on(&client, &addr, &session, "ROLLBACK").await;
2446        assert_eq!(r.status(), 200);
2447        assert_eq!(
2448            count_items(&client, &addr).await,
2449            0,
2450            "rollback discards staged writes"
2451        );
2452    }
2453
2454    #[tokio::test]
2455    async fn unknown_session_id_is_404() {
2456        let (_dir, addr) = setup().await;
2457        let client = reqwest::Client::new();
2458        let resp = client
2459            .post(format!("http://{addr}/sql"))
2460            .header("X-Session-ID", "does-not-exist")
2461            .json(&json!({ "sql": "SELECT 1" }))
2462            .send()
2463            .await
2464            .unwrap();
2465        assert_eq!(resp.status(), 404);
2466    }
2467
2468    #[tokio::test]
2469    async fn close_session_ends_cross_request_state() {
2470        let (_dir, addr) = setup().await;
2471        let client = reqwest::Client::new();
2472        let session = open_session(&client, &addr).await;
2473
2474        // BEGIN on the session, then close it → staged txn is discarded.
2475        sql_on(&client, &addr, &session, "BEGIN").await;
2476        let r = client
2477            .delete(format!("http://{addr}/sessions/{session}"))
2478            .send()
2479            .await
2480            .unwrap();
2481        assert_eq!(r.status(), 200);
2482
2483        // The session token is now invalid.
2484        let resp = sql_on(&client, &addr, &session, "COMMIT").await;
2485        assert_eq!(resp.status(), 404, "closed session is no longer usable");
2486    }
2487
2488    #[tokio::test]
2489    async fn no_session_header_uses_fresh_ephemeral_session() {
2490        // Without X-Session-ID, BEGIN..COMMIT must still work within a single
2491        // multi-statement /sql body (the historical behavior is preserved).
2492        let (_dir, addr) = setup().await;
2493        let client = reqwest::Client::new();
2494        let resp = client
2495            .post(format!("http://{addr}/sql"))
2496            .json(&json!({ "sql": "INSERT INTO items (id) VALUES (42)" }))
2497            .send()
2498            .await
2499            .unwrap();
2500        assert_eq!(resp.status(), 200);
2501        assert_eq!(count_items(&client, &addr).await, 1);
2502    }
2503
2504    #[tokio::test]
2505    async fn prepared_statement_prepare_execute_and_reuse() {
2506        let (_dir, addr) = setup().await;
2507        let client = reqwest::Client::new();
2508        let session = open_session(&client, &addr).await;
2509        sql_on(
2510            &client,
2511            &addr,
2512            &session,
2513            "INSERT INTO items (id) VALUES (1), (2), (3), (4)",
2514        )
2515        .await;
2516
2517        // Prepare a parameterized query once.
2518        let resp = client
2519            .post(format!("http://{addr}/sessions/{session}/prepare"))
2520            .json(&json!({"name":"gt","sql":"SELECT id FROM items WHERE id > $1"}))
2521            .send()
2522            .await
2523            .unwrap();
2524        assert_eq!(resp.status(), 200);
2525
2526        // Execute with param 2 → ids 3,4.
2527        let resp = client
2528            .post(format!("http://{addr}/sessions/{session}/execute"))
2529            .json(&json!({"name":"gt","params":[2]}))
2530            .send()
2531            .await
2532            .unwrap();
2533        assert_eq!(resp.status(), 200);
2534        let body = resp.json::<serde_json::Value>().await.unwrap();
2535        let arr = body
2536            .as_array()
2537            .expect("execute returns a JSON array of rows");
2538        assert_eq!(arr.len(), 2, "ids > 2 are {{3,4}}: {body}");
2539
2540        // Re-execute with a different param → reuses the cached plan, fewer rows.
2541        let resp = client
2542            .post(format!("http://{addr}/sessions/{session}/execute"))
2543            .json(&json!({"name":"gt","params":[3]}))
2544            .send()
2545            .await
2546            .unwrap();
2547        let body = resp.json::<serde_json::Value>().await.unwrap();
2548        assert_eq!(body.as_array().unwrap().len(), 1, "ids > 3 is {{4}}");
2549    }
2550
2551    #[tokio::test]
2552    async fn prepared_statement_deallocate_then_execute_fails() {
2553        let (_dir, addr) = setup().await;
2554        let client = reqwest::Client::new();
2555        let session = open_session(&client, &addr).await;
2556        let _ = client
2557            .post(format!("http://{addr}/sessions/{session}/prepare"))
2558            .json(&json!({"name":"p","sql":"SELECT $1"}))
2559            .send()
2560            .await
2561            .unwrap();
2562        let resp = client
2563            .delete(format!("http://{addr}/sessions/{session}/statements/p"))
2564            .send()
2565            .await
2566            .unwrap();
2567        assert_eq!(resp.status(), 200);
2568        // Execute after deallocate must error.
2569        let resp = client
2570            .post(format!("http://{addr}/sessions/{session}/execute"))
2571            .json(&json!({"name":"p","params":[1]}))
2572            .send()
2573            .await
2574            .unwrap();
2575        assert_ne!(resp.status(), 200, "execute after DEALLOCATE must fail");
2576    }
2577
2578    #[tokio::test]
2579    async fn prepared_statement_rejects_bad_name() {
2580        let (_dir, addr) = setup().await;
2581        let client = reqwest::Client::new();
2582        let session = open_session(&client, &addr).await;
2583        let resp = client
2584            .post(format!("http://{addr}/sessions/{session}/prepare"))
2585            .json(&json!({"name":"1bad","sql":"SELECT 1"}))
2586            .send()
2587            .await
2588            .unwrap();
2589        assert_eq!(
2590            resp.status(),
2591            400,
2592            "statement name starting with a digit must be rejected"
2593        );
2594    }
2595}