Skip to main content

mongreldb_server/
lib.rs

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