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