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