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