Skip to main content

mongreldb_server/
lib.rs

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