Skip to main content

mongreldb_server/
lib.rs

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