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    if mongreldb_query::contains_boolean_ai_predicate(&req.sql) {
969        return remote_boolean_ai_error();
970    }
971    let owner = request_owner(&state, &principal);
972    let Some(entry) = state.sessions.get(&id, &owner) else {
973        return (
974            StatusCode::NOT_FOUND,
975            "session not found or not owned by caller",
976        )
977            .into_response();
978    };
979    let _guard = entry.lock.lock().await;
980    if entry.is_closed() {
981        return (StatusCode::NOT_FOUND, "session no longer available").into_response();
982    }
983    entry.touch();
984    let sql = format!("PREPARE {} AS {}", req.name, req.sql);
985    match entry.session.run(&sql).await {
986        Ok(_) => Json(json!({ "prepared": req.name })).into_response(),
987        Err(e) => (status_for_query_error(&e), e.to_string()).into_response(),
988    }
989}
990
991#[derive(Deserialize)]
992struct ExecuteRequest {
993    name: String,
994    params: Vec<serde_json::Value>,
995    #[serde(default)]
996    format: Option<String>,
997}
998
999/// `POST /sessions/{id}/execute` — run a previously-prepared statement with
1000/// typed parameters, reusing its cached plan. Returns the same formats as
1001/// `/sql` (`json` default, `arrow`, `arrow-stream`).
1002async fn execute_statement(
1003    State(state): State<Arc<AppState>>,
1004    OptionalPrincipal(principal): OptionalPrincipal,
1005    Path(id): Path<String>,
1006    Json(req): Json<ExecuteRequest>,
1007) -> Response {
1008    if let Err(msg) = validate_stmt_name(&req.name) {
1009        return (StatusCode::BAD_REQUEST, msg).into_response();
1010    }
1011    let owner = request_owner(&state, &principal);
1012    let Some(entry) = state.sessions.get(&id, &owner) else {
1013        return (
1014            StatusCode::NOT_FOUND,
1015            "session not found or not owned by caller",
1016        )
1017            .into_response();
1018    };
1019    let _guard = entry.lock.lock().await;
1020    if entry.is_closed() {
1021        return (StatusCode::NOT_FOUND, "session no longer available").into_response();
1022    }
1023    entry.touch();
1024    state.metrics.inc_sql_queries();
1025    let literals: Vec<String> = match req
1026        .params
1027        .iter()
1028        .map(render_sql_literal)
1029        .collect::<Result<_, _>>()
1030    {
1031        Ok(v) => v,
1032        Err(msg) => {
1033            state.metrics.inc_sql_errors();
1034            return (StatusCode::BAD_REQUEST, msg).into_response();
1035        }
1036    };
1037    let sql = format!("EXECUTE {}({})", req.name, literals.join(", "));
1038    let start = std::time::Instant::now();
1039    let result = if req.format.as_deref() == Some("arrow-stream") {
1040        entry
1041            .session
1042            .run_stream(&sql)
1043            .await
1044            .map(sql_arrow_stream_response)
1045    } else {
1046        entry
1047            .session
1048            .run(&sql)
1049            .await
1050            .map(|batches| dispatch_buffered_sql_format(req.format.as_deref(), &batches))
1051    };
1052    let elapsed = start.elapsed();
1053    if elapsed >= state.slow_query_threshold {
1054        state.metrics.inc_slow_queries();
1055        eprintln!(
1056            "[slow-query] {}\u{00b5}s \u{2014} EXECUTE {}",
1057            elapsed.as_micros(),
1058            req.name
1059        );
1060    }
1061    match result {
1062        Ok(response) => response,
1063        Err(e) => {
1064            state.metrics.inc_sql_errors();
1065            // A reference to an unprepared/unknown statement is a client error.
1066            let msg = format!("{e}");
1067            let status = if msg.contains("does not exist") {
1068                StatusCode::NOT_FOUND
1069            } else {
1070                status_for_query_error(&e)
1071            };
1072            (status, format!("{msg} ({}µs)", elapsed.as_micros())).into_response()
1073        }
1074    }
1075}
1076
1077/// `DELETE /sessions/{id}/statements/{name}` — drop a prepared statement from
1078/// the session (SQL `DEALLOCATE`).
1079async fn deallocate_statement(
1080    State(state): State<Arc<AppState>>,
1081    OptionalPrincipal(principal): OptionalPrincipal,
1082    Path((id, name)): Path<(String, String)>,
1083) -> Response {
1084    if let Err(msg) = validate_stmt_name(&name) {
1085        return (StatusCode::BAD_REQUEST, msg).into_response();
1086    }
1087    let owner = request_owner(&state, &principal);
1088    let Some(entry) = state.sessions.get(&id, &owner) else {
1089        return (
1090            StatusCode::NOT_FOUND,
1091            "session not found or not owned by caller",
1092        )
1093            .into_response();
1094    };
1095    let _guard = entry.lock.lock().await;
1096    if entry.is_closed() {
1097        return (StatusCode::NOT_FOUND, "session no longer available").into_response();
1098    }
1099    entry.touch();
1100    let sql = format!("DEALLOCATE {name}");
1101    match entry.session.run(&sql).await {
1102        Ok(_) => Json(json!({ "deallocated": name })).into_response(),
1103        Err(e) => (status_for_query_error(&e), e.to_string()).into_response(),
1104    }
1105}
1106
1107/// `mongreldb_tables` gauge. Subject to the same auth middleware as every other
1108/// route (scrape with the configured Bearer token / Basic credentials).
1109async fn metrics_handler(
1110    State(state): State<Arc<AppState>>,
1111    OptionalPrincipal(principal): OptionalPrincipal,
1112) -> Response {
1113    if let Err(error) = state.db.require_for(
1114        request_principal(&state, &principal).as_ref(),
1115        &mongreldb_core::Permission::Admin,
1116    ) {
1117        return (status_for_error(&error), error.to_string()).into_response();
1118    }
1119    let body = state.metrics.prometheus_text(state.db.table_names().len());
1120    (
1121        [(
1122            header::CONTENT_TYPE,
1123            "text/plain; version=0.0.4; charset=utf-8".to_string(),
1124        )],
1125        body,
1126    )
1127        .into_response()
1128}
1129
1130/// `POST /compact` — compact all mounted tables.
1131async fn compact_all(
1132    State(state): State<Arc<AppState>>,
1133    OptionalPrincipal(principal): OptionalPrincipal,
1134) -> (StatusCode, Json<serde_json::Value>) {
1135    if let Err(error) = state.db.require_for(
1136        request_principal(&state, &principal).as_ref(),
1137        &mongreldb_core::Permission::Ddl,
1138    ) {
1139        return (
1140            status_for_error(&error),
1141            Json(json!({ "status": "error", "message": error.to_string() })),
1142        );
1143    }
1144    match state.db.compact() {
1145        Ok((compacted, skipped)) => (
1146            StatusCode::OK,
1147            Json(json!({
1148                "status": "ok",
1149                "compacted": compacted,
1150                "skipped": skipped,
1151            })),
1152        ),
1153        Err(e) => (
1154            StatusCode::INTERNAL_SERVER_ERROR,
1155            Json(json!({ "status": "error", "message": format!("{e}") })),
1156        ),
1157    }
1158}
1159
1160/// `POST /tables/{name}/compact` — compact a single table.
1161async fn compact_table(
1162    State(state): State<Arc<AppState>>,
1163    OptionalPrincipal(principal): OptionalPrincipal,
1164    Path(name): Path<String>,
1165) -> (StatusCode, Json<serde_json::Value>) {
1166    if let Err(error) = state.db.require_for(
1167        request_principal(&state, &principal).as_ref(),
1168        &mongreldb_core::Permission::Ddl,
1169    ) {
1170        return (
1171            status_for_error(&error),
1172            Json(json!({ "status": "error", "table": name, "message": error.to_string() })),
1173        );
1174    }
1175    match state.db.compact_table(&name) {
1176        Ok(true) => (
1177            StatusCode::OK,
1178            Json(json!({ "status": "compacted", "table": name })),
1179        ),
1180        Ok(false) => (
1181            StatusCode::OK,
1182            Json(json!({ "status": "skipped", "table": name, "reason": "fewer than 2 runs" })),
1183        ),
1184        Err(e) => (
1185            StatusCode::INTERNAL_SERVER_ERROR,
1186            Json(json!({ "status": "error", "table": name, "message": format!("{e}") })),
1187        ),
1188    }
1189}
1190
1191#[derive(Deserialize)]
1192struct CreateTableRequest {
1193    name: String,
1194    columns: Vec<ColumnDefJson>,
1195}
1196
1197#[derive(Deserialize)]
1198struct ColumnDefJson {
1199    id: u16,
1200    name: String,
1201    ty: String,
1202    primary_key: bool,
1203    #[serde(default)]
1204    nullable: bool,
1205}
1206
1207async fn create_table(
1208    State(state): State<Arc<AppState>>,
1209    OptionalPrincipal(principal): OptionalPrincipal,
1210    Json(req): Json<CreateTableRequest>,
1211) -> Response {
1212    if let Err(error) = state.db.require_for(
1213        request_principal(&state, &principal).as_ref(),
1214        &mongreldb_core::Permission::Ddl,
1215    ) {
1216        return (status_for_error(&error), error.to_string()).into_response();
1217    }
1218    let mut columns = Vec::new();
1219    for c in &req.columns {
1220        let ty = match c.ty.as_str() {
1221            "int64" | "bigint" => TypeId::Int64,
1222            "float64" | "double" => TypeId::Float64,
1223            "bytes" | "varchar" | "text" => TypeId::Bytes,
1224            "bool" => TypeId::Bool,
1225            other => {
1226                return (StatusCode::BAD_REQUEST, format!("unknown type: {other}")).into_response()
1227            }
1228        };
1229        let mut flags = mongreldb_core::schema::ColumnFlags::empty();
1230        if c.primary_key {
1231            flags = flags.with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY);
1232        }
1233        if c.nullable {
1234            flags = flags.with(mongreldb_core::schema::ColumnFlags::NULLABLE);
1235        }
1236        columns.push(mongreldb_core::schema::ColumnDef {
1237            id: c.id,
1238            name: c.name.clone(),
1239            ty,
1240            flags,
1241            default_value: None,
1242        });
1243    }
1244    let schema = Schema {
1245        schema_id: 0,
1246        columns,
1247        indexes: vec![],
1248        colocation: vec![],
1249        constraints: Default::default(),
1250        clustered: false,
1251    };
1252    if let Err(msg) = validate_table_name(&req.name) {
1253        return (StatusCode::BAD_REQUEST, msg).into_response();
1254    }
1255    match state.db.create_table(&req.name, schema) {
1256        Ok(id) => Json(json!({ "table_id": id })).into_response(),
1257        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1258    }
1259}
1260
1261async fn list_tables(
1262    State(state): State<Arc<AppState>>,
1263    OptionalPrincipal(principal): OptionalPrincipal,
1264) -> Json<Vec<String>> {
1265    let principal = request_principal(&state, &principal);
1266    Json(
1267        state
1268            .db
1269            .table_names()
1270            .into_iter()
1271            .filter(|table| {
1272                state
1273                    .db
1274                    .select_column_ids_for(table, principal.as_ref())
1275                    .is_ok()
1276            })
1277            .collect(),
1278    )
1279}
1280
1281async fn drop_table(
1282    State(state): State<Arc<AppState>>,
1283    OptionalPrincipal(principal): OptionalPrincipal,
1284    Path(name): Path<String>,
1285) -> Response {
1286    if let Err(error) = state.db.require_for(
1287        request_principal(&state, &principal).as_ref(),
1288        &mongreldb_core::Permission::Ddl,
1289    ) {
1290        return (status_for_error(&error), error.to_string()).into_response();
1291    }
1292    match state.db.drop_table(&name) {
1293        Ok(_) => {
1294            // Invalidate cached idempotency entries. A cached transaction
1295            // may reference the dropped table; replaying it would silently
1296            // report success without writing to the recreated table.
1297            state.idem.clear();
1298            StatusCode::OK.into_response()
1299        }
1300        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1301    }
1302}
1303
1304#[derive(Deserialize)]
1305struct PutRequest {
1306    row: Vec<serde_json::Value>,
1307}
1308
1309pub(crate) fn json_to_value(v: &serde_json::Value, expected: &TypeId) -> Value {
1310    match (v, expected) {
1311        (serde_json::Value::Number(n), TypeId::Float64) => {
1312            n.as_f64().map(Value::Float64).unwrap_or(Value::Null)
1313        }
1314        (serde_json::Value::Number(n), TypeId::Int64) => {
1315            n.as_i64().map(Value::Int64).unwrap_or(Value::Null)
1316        }
1317        (serde_json::Value::String(s), TypeId::Bytes) => Value::Bytes(s.as_bytes().to_vec()),
1318        (serde_json::Value::String(s), TypeId::Enum { variants }) => {
1319            if variants.iter().any(|v| v == s) {
1320                Value::Bytes(s.as_bytes().to_vec())
1321            } else {
1322                Value::Null
1323            }
1324        }
1325        (serde_json::Value::Bool(b), TypeId::Bool) => Value::Bool(*b),
1326        // Embedding input: a JSON array of numbers, validated against the
1327        // declared dimension. Mismatched length or non-numeric elements → Null.
1328        (serde_json::Value::Array(arr), TypeId::Embedding { dim }) => {
1329            if arr.len() as u32 != *dim {
1330                return Value::Null;
1331            }
1332            let vec: Option<Vec<f32>> =
1333                arr.iter().map(|el| el.as_f64().map(|f| f as f32)).collect();
1334            vec.map(Value::Embedding).unwrap_or(Value::Null)
1335        }
1336        (serde_json::Value::Null, _) => Value::Null,
1337        // Lenient fallbacks for unknown/loosely-typed JSON.
1338        (serde_json::Value::Number(n), _) => {
1339            if let Some(i) = n.as_i64() {
1340                Value::Int64(i)
1341            } else if let Some(f) = n.as_f64() {
1342                Value::Float64(f)
1343            } else {
1344                Value::Null
1345            }
1346        }
1347        (serde_json::Value::String(s), _) => Value::Bytes(s.as_bytes().to_vec()),
1348        (serde_json::Value::Bool(b), _) => Value::Bool(*b),
1349        _ => Value::Null,
1350    }
1351}
1352
1353/// Parse a flat JSON array `[col_id, val, col_id, val, ...]` into typed cell
1354/// pairs, validating the schema. Returns `Err(message)` on any malformed pair.
1355fn parse_cells(
1356    row: &[serde_json::Value],
1357    schema: &mongreldb_core::schema::Schema,
1358) -> Result<Vec<(u16, Value)>, String> {
1359    if row.len() & 1 != 0 {
1360        return Err("row must be an even-length array of [col_id, value] pairs".into());
1361    }
1362    let mut out = Vec::with_capacity(row.len() / 2);
1363    for chunk in row.chunks(2) {
1364        let col_id = chunk[0]
1365            .as_u64()
1366            .ok_or("column id must be a non-negative integer")? as u16;
1367        let expected = schema
1368            .columns
1369            .iter()
1370            .find(|c| c.id == col_id)
1371            .map(|c| c.ty.clone())
1372            .ok_or_else(|| format!("unknown column id {col_id}"))?;
1373        let val = json_to_value(&chunk[1], &expected);
1374        out.push((col_id, val));
1375    }
1376    Ok(out)
1377}
1378
1379/// Basic validation for a table name: non-empty and no path separators.
1380pub(crate) fn validate_table_name(name: &str) -> Result<(), String> {
1381    if name.is_empty() {
1382        return Err("table name must not be empty".into());
1383    }
1384    if name.contains('/') || name.contains('\\') || name.contains('\0') {
1385        return Err("table name contains invalid characters".into());
1386    }
1387    Ok(())
1388}
1389
1390async fn put_row(
1391    State(state): State<Arc<AppState>>,
1392    OptionalPrincipal(principal): OptionalPrincipal,
1393    Path(name): Path<String>,
1394    Json(req): Json<PutRequest>,
1395) -> Response {
1396    let handle = match state.db.table(&name) {
1397        Ok(h) => h,
1398        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
1399    };
1400    let schema = handle.lock().schema().clone();
1401    let row = match parse_cells(&req.row, &schema) {
1402        Ok(r) => r,
1403        Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
1404    };
1405    state.metrics.inc_puts();
1406    let principal = request_principal(&state, &principal);
1407    match state.db.put_for(&name, row, principal.as_ref()) {
1408        Ok(rid) => Json(json!({ "row_id": rid.0.to_string() })).into_response(),
1409        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1410    }
1411}
1412
1413async fn count(
1414    State(state): State<Arc<AppState>>,
1415    OptionalPrincipal(principal): OptionalPrincipal,
1416    Path(name): Path<String>,
1417) -> Response {
1418    let principal = request_principal(&state, &principal);
1419    match state.db.count_for(&name, principal.as_ref()) {
1420        Ok(count) => Json(json!({ "count": count })).into_response(),
1421        Err(error) => (status_for_error(&error), error.to_string()).into_response(),
1422    }
1423}
1424
1425async fn commit(
1426    State(state): State<Arc<AppState>>,
1427    OptionalPrincipal(principal): OptionalPrincipal,
1428    Path(name): Path<String>,
1429) -> Response {
1430    if let Err(error) = state.db.require_for(
1431        request_principal(&state, &principal).as_ref(),
1432        &mongreldb_core::Permission::Update {
1433            table: name.clone(),
1434        },
1435    ) {
1436        return (status_for_error(&error), error.to_string()).into_response();
1437    }
1438    let handle = match state.db.table(&name) {
1439        Ok(h) => h,
1440        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
1441    };
1442    let mut g = handle.lock();
1443    state.metrics.inc_commits();
1444    match g.commit() {
1445        Ok(epoch) => Json(json!({ "epoch": epoch.0 })).into_response(),
1446        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1447    }
1448}
1449
1450#[derive(Deserialize)]
1451struct SqlRequest {
1452    sql: String,
1453    /// Output format: `"json"` (the default) for a JSON array of row objects,
1454    /// `"arrow"` for Arrow IPC file bytes.
1455    #[serde(default)]
1456    format: Option<String>,
1457}
1458
1459async fn sql(
1460    State(state): State<Arc<AppState>>,
1461    OptionalPrincipal(principal): OptionalPrincipal,
1462    headers: axum::http::HeaderMap,
1463    Json(req): Json<SqlRequest>,
1464) -> Response {
1465    // Session routing: an `X-Session-ID` header routes the request to a pooled
1466    // long-lived session, enabling cross-request `BEGIN`/`INSERT`/`COMMIT`
1467    // transactions. Without the header, a fresh ephemeral session is used
1468    // (the historical behavior).
1469    let session_id = headers
1470        .get("x-session-id")
1471        .and_then(|v| v.to_str().ok())
1472        .map(str::to_owned);
1473
1474    if let Some(sid) = session_id {
1475        let owner = request_owner(&state, &principal);
1476        let Some(entry) = state.sessions.get(&sid, &owner) else {
1477            return (
1478                StatusCode::NOT_FOUND,
1479                "session not found or not owned by caller",
1480            )
1481                .into_response();
1482        };
1483        // Serialize per-session access so two concurrent requests on the same
1484        // token cannot interleave a transaction's staged writes.
1485        let _guard = entry.lock.lock().await;
1486        // Re-check closed: the session may have been closed/evicted between
1487        // get() and acquiring the lock.
1488        if entry.is_closed() {
1489            return (StatusCode::NOT_FOUND, "session no longer available").into_response();
1490        }
1491        entry.touch();
1492        execute_sql(&state, &principal, &entry.session, req).await
1493    } else {
1494        let session = match MongrelSession::open_with_external_modules_as(
1495            Arc::clone(&state.db),
1496            state.external_modules.iter().cloned(),
1497            request_principal(&state, &principal),
1498        ) {
1499            Ok(s) => s,
1500            Err(e) => return (status_for_query_error(&e), e.to_string()).into_response(),
1501        };
1502        execute_sql(&state, &principal, &session, req).await
1503    }
1504}
1505
1506/// Run one SQL request against a given session: bump counters, audit DDL
1507/// (after execution, with redaction), log slow queries on both success and
1508/// failure, and dispatch the response format. Shared by the pooled-session and
1509/// fresh-session paths.
1510async fn execute_sql(
1511    state: &AppState,
1512    principal: &Option<mongreldb_core::Principal>,
1513    session: &MongrelSession,
1514    req: SqlRequest,
1515) -> Response {
1516    if mongreldb_query::contains_boolean_ai_predicate(&req.sql) {
1517        return remote_boolean_ai_error();
1518    }
1519    state.metrics.inc_sql_queries();
1520    let audited = audit::is_audited_sql(&req.sql);
1521    let actor = request_owner(state, principal);
1522    let start = std::time::Instant::now();
1523    // NOTE: deliberately NOT using `run_sql_traced` here. Its thread-local
1524    // push/pop spans an `.await`, and on a multi-threaded tokio runtime the
1525    // task can resume on a different thread, corrupting the trace stack and
1526    // leaking scopes. Wall-clock timing is sufficient for slow-query detection
1527    // and works across awaits.
1528    let result = if req.format.as_deref() == Some("arrow-stream") {
1529        session
1530            .run_stream(&req.sql)
1531            .await
1532            .map(sql_arrow_stream_response)
1533    } else {
1534        session
1535            .run(&req.sql)
1536            .await
1537            .map(|batches| dispatch_buffered_sql_format(req.format.as_deref(), &batches))
1538    };
1539    let elapsed = start.elapsed();
1540    // Slow-query logging covers BOTH success and failure (the slowest errors
1541    // matter most for diagnosis), checked before branching on the outcome.
1542    if elapsed >= state.slow_query_threshold {
1543        state.metrics.inc_slow_queries();
1544        let preview: String = req.sql.chars().take(80).collect();
1545        eprintln!(
1546            "[slow-query] {}\u{00b5}s \u{2014} {preview}",
1547            elapsed.as_micros()
1548        );
1549    }
1550    // Audit DDL/privilege AFTER execution so the outcome (ok/fail) is captured.
1551    // `redacted_ddl_detail` never logs credential literals.
1552    if audited {
1553        let (action, detail) = audit::redacted_ddl_detail(&req.sql, result.is_ok());
1554        state.audit.record(actor, action, detail);
1555    }
1556    match result {
1557        Ok(response) => response,
1558        Err(e) => {
1559            state.metrics.inc_sql_errors();
1560            (
1561                status_for_query_error(&e),
1562                format!("{e} ({}µs)", elapsed.as_micros()),
1563            )
1564                .into_response()
1565        }
1566    }
1567}
1568
1569fn remote_boolean_ai_error() -> Response {
1570    (
1571        StatusCode::BAD_REQUEST,
1572        "Boolean ANN/Sparse SQL is disabled remotely; use scored SQL functions",
1573    )
1574        .into_response()
1575}
1576
1577/// Serialize Arrow record batches as Arrow IPC file bytes. This is the
1578/// high-performance binary format for clients with Arrow library support.
1579fn sql_arrow_response(batches: &[arrow::record_batch::RecordBatch]) -> Response {
1580    if batches.is_empty() {
1581        return StatusCode::OK.into_response();
1582    }
1583    let schema = batches[0].schema();
1584    let mut buf = Vec::new();
1585    let mut writer = arrow::ipc::writer::FileWriter::try_new(&mut buf, schema.as_ref()).unwrap();
1586    for b in batches {
1587        let _ = writer.write(b);
1588    }
1589    let _ = writer.finish();
1590    drop(writer);
1591    (
1592        [(header::CONTENT_TYPE, "application/vnd.apache.arrow.file")],
1593        buf,
1594    )
1595        .into_response()
1596}
1597
1598/// Serialize a DataFusion record-batch stream as Arrow streaming IPC. The body
1599/// holds only the active query batch and one serialized IPC message.
1600fn sql_arrow_stream_response(batches: mongreldb_query::MongrelRecordBatchStream) -> Response {
1601    use futures::{stream, StreamExt};
1602
1603    const STREAM_CT: &str = "application/vnd.apache.arrow.stream";
1604
1605    let schema = batches.schema();
1606    let mut writer = match arrow::ipc::writer::StreamWriter::try_new(Vec::new(), schema.as_ref()) {
1607        Ok(w) => w,
1608        Err(e) => {
1609            return (
1610                StatusCode::INTERNAL_SERVER_ERROR,
1611                format!("arrow stream init error: {e}"),
1612            )
1613                .into_response()
1614        }
1615    };
1616    // `try_new` synchronously writes the schema message; drain it so it becomes
1617    // the first chunk yielded to the client.
1618    let schema_chunk: Vec<u8> = std::mem::take(writer.get_mut());
1619    let batch_stream = stream::unfold(
1620        (batches, Some(writer)),
1621        |(mut batches, writer)| async move {
1622            let mut writer = writer?;
1623            match batches.next().await {
1624                Some(Ok(batch)) => match writer.write(&batch) {
1625                    Ok(()) => {
1626                        let chunk = std::mem::take(writer.get_mut());
1627                        Some((Ok(chunk), (batches, Some(writer))))
1628                    }
1629                    Err(error) => Some((Err(std::io::Error::other(error)), (batches, None))),
1630                },
1631                Some(Err(error)) => Some((Err(std::io::Error::other(error)), (batches, None))),
1632                None => match writer.finish() {
1633                    Ok(()) => {
1634                        let chunk = std::mem::take(writer.get_mut());
1635                        Some((Ok(chunk), (batches, None)))
1636                    }
1637                    Err(error) => Some((Err(std::io::Error::other(error)), (batches, None))),
1638                },
1639            }
1640        },
1641    );
1642
1643    // Schema first, then batches + EOS. Each item is already `Result<Vec<u8>,
1644    // io::Error>` so a mid-stream encode failure surfaces as a body error.
1645    let schema_item: Result<Vec<u8>, std::io::Error> = Ok(schema_chunk);
1646    let full = stream::iter([schema_item]).chain(batch_stream);
1647    let body = axum::body::Body::from_stream(full);
1648    ([(header::CONTENT_TYPE, STREAM_CT)], body).into_response()
1649}
1650
1651/// Serialize Arrow record batches into a JSON array of row objects using the
1652/// Arrow JSON writer. Each row becomes a JSON object keyed by column name.
1653/// This handles all Arrow types correctly (Decimal128, Timestamp, Date32, List,
1654/// etc.) and streams directly into a byte buffer without intermediate
1655/// serde_json::Value allocations.
1656fn sql_json_response(batches: &[arrow::record_batch::RecordBatch]) -> Response {
1657    if batches.is_empty() {
1658        return ([(header::CONTENT_TYPE, "application/json")], b"[]" as &[u8]).into_response();
1659    }
1660
1661    let mut buf = Vec::new();
1662    {
1663        let mut writer = arrow::json::writer::ArrayWriter::new(&mut buf);
1664        let refs: Vec<&_> = batches.iter().collect();
1665        if let Err(e) = writer.write_batches(&refs) {
1666            return (
1667                StatusCode::INTERNAL_SERVER_ERROR,
1668                format!("JSON serialization error: {e}"),
1669            )
1670                .into_response();
1671        }
1672        let _ = writer.finish();
1673    }
1674
1675    ([(header::CONTENT_TYPE, "application/json")], buf).into_response()
1676}
1677
1678#[derive(Deserialize)]
1679struct TxnOp {
1680    table: String,
1681    op: String,
1682    cells: Option<Vec<serde_json::Value>>,
1683    row_id: Option<u64>,
1684}
1685
1686#[derive(Deserialize)]
1687struct TxnRequest {
1688    ops: Vec<TxnOp>,
1689}
1690
1691async fn txn(
1692    State(state): State<Arc<AppState>>,
1693    OptionalPrincipal(principal): OptionalPrincipal,
1694    Json(req): Json<TxnRequest>,
1695) -> Response {
1696    // Pre-validate every op against the live schemas before entering the
1697    // transaction, so malformed input returns 400 without consuming an epoch
1698    // or poisoning a txn.
1699    let mut parsed: Vec<(String, TxnAction)> = Vec::with_capacity(req.ops.len());
1700    for op in &req.ops {
1701        match op.op.as_str() {
1702            "put" => {
1703                let cells_json = match op.cells.as_ref() {
1704                    Some(c) if !c.is_empty() => c,
1705                    _ => {
1706                        return (StatusCode::BAD_REQUEST, "put op requires non-empty cells")
1707                            .into_response()
1708                    }
1709                };
1710                let handle = match state.db.table(&op.table) {
1711                    Ok(h) => h,
1712                    Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
1713                };
1714                let schema = handle.lock().schema().clone();
1715                let cells = match parse_cells(cells_json, &schema) {
1716                    Ok(c) => c,
1717                    Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
1718                };
1719                parsed.push((op.table.clone(), TxnAction::Put(cells)));
1720            }
1721            "delete" => {
1722                let rid = match op.row_id {
1723                    Some(r) => r,
1724                    None => {
1725                        return (StatusCode::BAD_REQUEST, "delete op requires row_id")
1726                            .into_response()
1727                    }
1728                };
1729                parsed.push((op.table.clone(), TxnAction::Delete(rid)));
1730            }
1731            other => {
1732                return (StatusCode::BAD_REQUEST, format!("unknown op: {other}")).into_response()
1733            }
1734        }
1735    }
1736
1737    state.metrics.inc_txns();
1738    let mut transaction = state.db.begin_as(request_principal(&state, &principal));
1739    let result = (|| {
1740        for (table, action) in &parsed {
1741            match action {
1742                TxnAction::Put(cells) => {
1743                    transaction.put(table, cells.clone())?;
1744                }
1745                TxnAction::Delete(rid) => {
1746                    transaction.delete(table, mongreldb_core::RowId(*rid))?;
1747                }
1748            }
1749        }
1750        transaction.commit()
1751    })();
1752    match result {
1753        Ok(_) => Json(json!({ "status": "committed" })).into_response(),
1754        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
1755    }
1756}
1757
1758enum TxnAction {
1759    Put(Vec<(u16, Value)>),
1760    Delete(u64),
1761}
1762
1763#[cfg(test)]
1764mod auth_tests {
1765    use super::*;
1766    use mongreldb_core::Database;
1767    use tempfile::tempdir;
1768
1769    #[tokio::test]
1770    async fn auth_rejects_missing_token() {
1771        let dir = tempdir().unwrap();
1772        let db = Arc::new(Database::create(dir.path()).unwrap());
1773        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
1774        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1775        let addr = listener.local_addr().unwrap();
1776        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1777        // Give the server a moment to start.
1778        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1779
1780        let client = reqwest::Client::new();
1781        let resp = client
1782            .get(format!("http://{addr}/health"))
1783            .send()
1784            .await
1785            .unwrap();
1786        assert_eq!(resp.status(), 401);
1787    }
1788
1789    #[tokio::test]
1790    async fn auth_accepts_valid_token() {
1791        let dir = tempdir().unwrap();
1792        let db = Arc::new(Database::create(dir.path()).unwrap());
1793        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
1794        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1795        let addr = listener.local_addr().unwrap();
1796        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1797        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1798
1799        let client = reqwest::Client::new();
1800        let resp = client
1801            .get(format!("http://{addr}/health"))
1802            .header("Authorization", "Bearer secret")
1803            .send()
1804            .await
1805            .unwrap();
1806        assert_eq!(resp.status(), 200);
1807    }
1808
1809    #[tokio::test]
1810    async fn no_auth_when_token_unset() {
1811        let dir = tempdir().unwrap();
1812        let db = Arc::new(Database::create(dir.path()).unwrap());
1813        let app = build_app_with_config(db, std::iter::empty(), None, None);
1814        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1815        let addr = listener.local_addr().unwrap();
1816        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1817        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1818
1819        let client = reqwest::Client::new();
1820        let resp = client
1821            .get(format!("http://{addr}/health"))
1822            .send()
1823            .await
1824            .unwrap();
1825        assert_eq!(resp.status(), 200);
1826    }
1827}
1828
1829#[cfg(test)]
1830mod wal_stream_tests {
1831    use super::*;
1832    use mongreldb_client::ReplicationFollower;
1833    use mongreldb_core::Database;
1834    use tempfile::tempdir;
1835
1836    #[tokio::test]
1837    async fn wal_stream_returns_records_after_commit() {
1838        let dir = tempdir().unwrap();
1839        let db = Arc::new(Database::create(dir.path()).unwrap());
1840        let table_schema = mongreldb_core::schema::Schema {
1841            schema_id: 1,
1842            columns: vec![mongreldb_core::schema::ColumnDef {
1843                id: 1,
1844                name: "id".into(),
1845                ty: TypeId::Int64,
1846                flags: mongreldb_core::schema::ColumnFlags::empty()
1847                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
1848                default_value: None,
1849            }],
1850            indexes: vec![],
1851            colocation: vec![],
1852            constraints: Default::default(),
1853            clustered: false,
1854        };
1855        db.create_table("items", table_schema).unwrap();
1856        // Write a row to generate WAL records.
1857        let handle = db.table("items").unwrap();
1858        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
1859        handle.lock().flush().unwrap();
1860
1861        let app = build_app(db);
1862        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1863        let addr = listener.local_addr().unwrap();
1864        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1865        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1866
1867        let resp = reqwest::get(format!("http://{addr}/wal/stream"))
1868            .await
1869            .unwrap();
1870        assert_eq!(resp.status(), 200);
1871        let body = resp.text().await.unwrap();
1872        // Should contain at least one record (the flush commit).
1873        assert!(!body.is_empty(), "wal_stream should return records");
1874        assert!(body.contains("seq"), "response should contain seq field");
1875    }
1876
1877    #[tokio::test]
1878    async fn follower_bootstraps_and_applies_incremental_commit() {
1879        let leader_dir = tempdir().unwrap();
1880        let follower_dir = tempdir().unwrap();
1881        let follower_path = follower_dir.path().join("copy");
1882        let db = Arc::new(Database::create(leader_dir.path()).unwrap());
1883        db.create_table(
1884            "items",
1885            mongreldb_core::schema::Schema {
1886                schema_id: 1,
1887                columns: vec![mongreldb_core::schema::ColumnDef {
1888                    id: 1,
1889                    name: "id".into(),
1890                    ty: TypeId::Int64,
1891                    flags: mongreldb_core::schema::ColumnFlags::empty()
1892                        .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
1893                    default_value: None,
1894                }],
1895                indexes: vec![],
1896                colocation: vec![],
1897                constraints: Default::default(),
1898                clustered: false,
1899            },
1900        )
1901        .unwrap();
1902        let handle = db.table("items").unwrap();
1903        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
1904        handle.lock().commit().unwrap();
1905
1906        let app = build_app_with_config(
1907            Arc::clone(&db),
1908            std::iter::empty::<Arc<dyn ExternalTableModule>>(),
1909            Some("replication-secret".into()),
1910            None,
1911        );
1912        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1913        let addr = listener.local_addr().unwrap();
1914        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1915        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1916
1917        let leader_url = format!("http://{addr}");
1918        let first_path = follower_path.clone();
1919        let (mut follower, initial) = tokio::task::spawn_blocking(move || {
1920            let mut follower = ReplicationFollower::new(&leader_url, first_path)
1921                .with_bearer_token("replication-secret");
1922            let applied = follower.sync().unwrap();
1923            (follower, applied)
1924        })
1925        .await
1926        .unwrap();
1927        assert_eq!(initial, 0);
1928
1929        handle.lock().put(vec![(1, Value::Int64(2))]).unwrap();
1930        handle.lock().commit().unwrap();
1931        let applied = tokio::task::spawn_blocking(move || {
1932            let count = follower.sync().unwrap();
1933            (follower, count)
1934        })
1935        .await
1936        .unwrap();
1937        follower = applied.0;
1938        assert!(applied.1 > 0);
1939        assert!(follower.last_epoch() > 0);
1940
1941        let replica = Database::open(&follower_path).unwrap();
1942        assert_eq!(replica.table("items").unwrap().lock().count(), 2);
1943        drop(replica);
1944
1945        db.set_spill_threshold(1);
1946        db.transaction(|txn| {
1947            txn.put("items", vec![(1, Value::Int64(3))])?;
1948            Ok(())
1949        })
1950        .unwrap();
1951        let (follower_after_bootstrap, applied) = tokio::task::spawn_blocking(move || {
1952            let count = follower.sync().unwrap();
1953            (follower, count)
1954        })
1955        .await
1956        .unwrap();
1957        assert_eq!(applied, 0, "spilled run should trigger safe rebootstrap");
1958        assert!(follower_after_bootstrap.last_epoch() > 0);
1959        let replica = Database::open(&follower_path).unwrap();
1960        assert_eq!(replica.table("items").unwrap().lock().count(), 3);
1961    }
1962}
1963
1964#[cfg(test)]
1965mod metrics_tests {
1966    use super::*;
1967    use mongreldb_core::Database;
1968    use tempfile::tempdir;
1969
1970    /// Helper: spin up a daemon over a fresh DB with one `items(id int64 pk)`
1971    /// table pre-created, returning the bound address.
1972    async fn setup() -> std::net::SocketAddr {
1973        let dir = tempdir().unwrap();
1974        let db = Arc::new(Database::create(dir.path()).unwrap());
1975        let table_schema = mongreldb_core::schema::Schema {
1976            schema_id: 1,
1977            columns: vec![mongreldb_core::schema::ColumnDef {
1978                id: 1,
1979                name: "id".into(),
1980                ty: TypeId::Int64,
1981                flags: mongreldb_core::schema::ColumnFlags::empty()
1982                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
1983                default_value: None,
1984            }],
1985            indexes: vec![],
1986            colocation: vec![],
1987            constraints: Default::default(),
1988            clustered: false,
1989        };
1990        db.create_table("items", table_schema).unwrap();
1991        let app = build_app(db);
1992        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1993        let addr = listener.local_addr().unwrap();
1994        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1995        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1996        addr
1997    }
1998
1999    #[tokio::test]
2000    async fn metrics_endpoint_returns_prometheus_text() {
2001        let addr = setup().await;
2002        let client = reqwest::Client::new();
2003
2004        // Exercise a few handlers to bump counters.
2005        let _ = client
2006            .post(format!("http://{addr}/tables/items/put"))
2007            .json(&json!({ "row": [1, 1] }))
2008            .send()
2009            .await
2010            .unwrap();
2011        let _ = client
2012            .post(format!("http://{addr}/sql"))
2013            .json(&json!({ "sql": "SELECT count(*) FROM items" }))
2014            .send()
2015            .await
2016            .unwrap();
2017
2018        let resp = client
2019            .get(format!("http://{addr}/metrics"))
2020            .send()
2021            .await
2022            .unwrap();
2023        assert_eq!(resp.status(), 200);
2024        let ct = resp
2025            .headers()
2026            .get("content-type")
2027            .and_then(|v| v.to_str().ok())
2028            .unwrap_or_default()
2029            .to_string();
2030        assert!(
2031            ct.contains("text/plain"),
2032            "content-type is prometheus text: {ct}"
2033        );
2034        let body = resp.text().await.unwrap();
2035        // Prometheus series + type lines are present.
2036        assert!(body.contains("# TYPE mongreldb_sql_queries_total counter"));
2037        assert!(body.contains("# TYPE mongreldb_puts_total counter"));
2038        assert!(body.contains("# TYPE mongreldb_tables gauge"));
2039        // Counters were bumped: at least one query and one put were served.
2040        assert!(
2041            body.contains("mongreldb_sql_queries_total 1"),
2042            "sql_queries counter should reflect the /sql call: {body}"
2043        );
2044        assert!(
2045            body.contains("mongreldb_puts_total 1"),
2046            "puts counter should reflect the put call: {body}"
2047        );
2048        // The tables gauge reflects the single `items` table.
2049        assert!(body.contains("mongreldb_tables 1"));
2050    }
2051
2052    #[tokio::test]
2053    async fn metrics_error_counter_increments_on_bad_sql() {
2054        let addr = setup().await;
2055        let client = reqwest::Client::new();
2056        // A query against a non-existent table errors at the engine layer.
2057        let _ = client
2058            .post(format!("http://{addr}/sql"))
2059            .json(&json!({ "sql": "SELECT * FROM does_not_exist" }))
2060            .send()
2061            .await
2062            .unwrap();
2063        let body = client
2064            .get(format!("http://{addr}/metrics"))
2065            .send()
2066            .await
2067            .unwrap()
2068            .text()
2069            .await
2070            .unwrap();
2071        assert!(
2072            body.contains("mongreldb_sql_errors_total 1"),
2073            "sql_errors should increment on a failed query: {body}"
2074        );
2075    }
2076
2077    #[tokio::test]
2078    async fn arrow_stream_returns_ipc_stream_bytes() {
2079        let addr = setup().await;
2080        let client = reqwest::Client::new();
2081        // Insert a couple of rows so there are real batches to stream, then
2082        // flush so the rows are durable/visible to a fresh SQL session.
2083        for i in 1..=3 {
2084            let resp = client
2085                .post(format!("http://{addr}/tables/items/put"))
2086                .json(&json!({ "row": [1, i] }))
2087                .send()
2088                .await
2089                .unwrap();
2090            assert_eq!(resp.status(), 200, "put should succeed");
2091        }
2092        let _ = client
2093            .post(format!("http://{addr}/tables/items/commit"))
2094            .send()
2095            .await
2096            .unwrap();
2097        // Sanity: the rows are durable and visible to the table handle.
2098        let count_body = client
2099            .get(format!("http://{addr}/tables/items/count"))
2100            .send()
2101            .await
2102            .unwrap()
2103            .text()
2104            .await
2105            .unwrap();
2106        assert!(
2107            count_body.contains("\"count\":3"),
2108            "expected 3 visible rows, got: {count_body}"
2109        );
2110        let resp = client
2111            .post(format!("http://{addr}/sql"))
2112            .json(&json!({ "sql": "SELECT count(*) FROM items", "format": "arrow-stream" }))
2113            .send()
2114            .await
2115            .unwrap();
2116        assert_eq!(resp.status(), 200, "streaming query should succeed");
2117        let ct = resp
2118            .headers()
2119            .get("content-type")
2120            .and_then(|v| v.to_str().ok())
2121            .unwrap_or_default()
2122            .to_string();
2123        assert!(
2124            ct.contains("application/vnd.apache.arrow.stream"),
2125            "content-type should be the arrow stream format: {ct}"
2126        );
2127        let bytes = resp.bytes().await.unwrap();
2128        // Arrow IPC streams begin with the magic continuation marker
2129        // 0xFFFFFFFF followed by the schema message length. The stream must be
2130        // non-empty (3 rows were written) and end with the EOS marker.
2131        assert!(
2132            !bytes.is_empty(),
2133            "arrow stream body should contain schema + batch + EOS"
2134        );
2135        assert!(
2136            bytes.starts_with(&0xFFFFFFFFu32.to_le_bytes()),
2137            "arrow stream must begin with the IPC continuation marker"
2138        );
2139        assert!(
2140            bytes.ends_with(&[0u8, 0, 0, 0]),
2141            "arrow stream should end with the EOS marker (trailing zero length)"
2142        );
2143    }
2144}
2145
2146#[cfg(test)]
2147mod streaming_tests {
2148    use super::*;
2149    use arrow::array::Int64Array;
2150    use arrow::datatypes::{DataType, Field, Schema};
2151    use arrow::record_batch::RecordBatch;
2152    use datafusion::common::DataFusionError;
2153    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
2154    use futures::StreamExt;
2155    use std::sync::Arc as StdArc;
2156
2157    fn batch_stream(batches: Vec<RecordBatch>) -> mongreldb_query::MongrelRecordBatchStream {
2158        let schema = batches
2159            .first()
2160            .map(RecordBatch::schema)
2161            .unwrap_or_else(|| StdArc::new(Schema::empty()));
2162        let batches =
2163            futures::stream::iter(batches.into_iter().map(Ok::<RecordBatch, DataFusionError>));
2164        Box::pin(RecordBatchStreamAdapter::new(schema, batches))
2165    }
2166
2167    /// Unit-level check: feed two synthetic batches through the streaming
2168    /// serializer and re-parse the resulting IPC stream end-to-end. This
2169    /// validates the per-message chunking (schema + N batches + EOS) without
2170    /// depending on the engine's scan visibility.
2171    #[tokio::test]
2172    async fn arrow_stream_serializes_multiple_batches_roundtrip() {
2173        let schema = StdArc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
2174        let b1 = RecordBatch::try_new(
2175            schema.clone(),
2176            vec![StdArc::new(Int64Array::from(vec![1, 2]))],
2177        )
2178        .unwrap();
2179        let b2 = RecordBatch::try_new(
2180            schema.clone(),
2181            vec![StdArc::new(Int64Array::from(vec![3, 4, 5]))],
2182        )
2183        .unwrap();
2184
2185        let resp = sql_arrow_stream_response(batch_stream(vec![b1, b2]));
2186        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
2187            .await
2188            .unwrap();
2189
2190        // Begins with the IPC continuation marker.
2191        assert!(bytes.starts_with(&0xFFFFFFFFu32.to_le_bytes()));
2192
2193        // Re-parse the full stream and confirm all rows survived the chunked
2194        // serialization.
2195        let slice: &[u8] = bytes.as_ref();
2196        let mut reader = arrow::ipc::reader::StreamReader::try_new(slice, None).unwrap();
2197        let mut total_rows = 0;
2198        for batch in reader.by_ref() {
2199            let batch = batch.expect("each IPC message should decode");
2200            total_rows += batch.num_rows();
2201        }
2202        assert_eq!(
2203            total_rows, 5,
2204            "all rows should round-trip through the stream"
2205        );
2206    }
2207
2208    #[tokio::test]
2209    async fn arrow_stream_emits_schema_before_first_batch() {
2210        let schema = StdArc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
2211        let pending = futures::stream::pending::<Result<RecordBatch, DataFusionError>>();
2212        let batches = Box::pin(RecordBatchStreamAdapter::new(schema, pending));
2213        let mut body = sql_arrow_stream_response(batches)
2214            .into_body()
2215            .into_data_stream();
2216
2217        let chunk = tokio::time::timeout(std::time::Duration::from_millis(100), body.next())
2218            .await
2219            .expect("schema chunk should not wait for a query batch")
2220            .unwrap()
2221            .unwrap();
2222        assert!(chunk.starts_with(&0xFFFFFFFFu32.to_le_bytes()));
2223    }
2224
2225    #[tokio::test]
2226    async fn arrow_stream_empty_query_is_valid_ipc() {
2227        let resp = sql_arrow_stream_response(batch_stream(Vec::new()));
2228        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
2229            .await
2230            .unwrap();
2231        let slice: &[u8] = bytes.as_ref();
2232        let reader = arrow::ipc::reader::StreamReader::try_new(slice, None).unwrap();
2233        assert_eq!(reader.count(), 0);
2234    }
2235}
2236
2237#[cfg(test)]
2238mod audit_tests {
2239    use super::*;
2240    use mongreldb_core::Database;
2241    use tempfile::tempdir;
2242
2243    /// Build a daemon with user-auth enabled plus one catalog user `alice`.
2244    async fn auth_setup(password: &str) -> std::net::SocketAddr {
2245        let dir = tempdir().unwrap();
2246        let db = Arc::new(Database::create(dir.path()).unwrap());
2247        db.create_user("alice", password).unwrap();
2248        db.set_user_admin("alice", true).unwrap();
2249        let app = build_app_full(
2250            db,
2251            std::iter::empty::<Arc<dyn ExternalTableModule>>(),
2252            None,
2253            None,
2254            true,
2255        );
2256        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2257        let addr = listener.local_addr().unwrap();
2258        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2259        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2260        addr
2261    }
2262
2263    #[tokio::test]
2264    async fn audit_records_login_success_and_failure() {
2265        let addr = auth_setup("s3cret").await;
2266        let client = reqwest::Client::new();
2267
2268        // Successful basic auth.
2269        let resp = client
2270            .get(format!("http://{addr}/health"))
2271            .header("Authorization", basic("alice", "s3cret"))
2272            .send()
2273            .await
2274            .unwrap();
2275        assert_eq!(resp.status(), 200);
2276
2277        // Failed basic auth (wrong password).
2278        let resp = client
2279            .get(format!("http://{addr}/health"))
2280            .header("Authorization", basic("alice", "wrong"))
2281            .send()
2282            .await
2283            .unwrap();
2284        assert_eq!(resp.status(), 401);
2285
2286        let body = client
2287            .get(format!("http://{addr}/audit"))
2288            .header("Authorization", basic("alice", "s3cret"))
2289            .send()
2290            .await
2291            .unwrap()
2292            .text()
2293            .await
2294            .unwrap();
2295        assert!(
2296            body.contains("\"action\":\"login.ok\""),
2297            "audit should record the successful login: {body}"
2298        );
2299        assert!(
2300            body.contains("\"action\":\"login.fail\""),
2301            "audit should record the failed login: {body}"
2302        );
2303        assert!(
2304            body.contains("\"principal\":\"alice\""),
2305            "audit should attribute events to alice: {body}"
2306        );
2307    }
2308
2309    #[tokio::test]
2310    async fn audit_records_ddl_sql() {
2311        let dir = tempdir().unwrap();
2312        let db = Arc::new(Database::create(dir.path()).unwrap());
2313        let app = build_app(db);
2314        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2315        let addr = listener.local_addr().unwrap();
2316        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2317        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2318
2319        let client = reqwest::Client::new();
2320        let _ = client
2321            .post(format!("http://{addr}/sql"))
2322            .json(&json!({ "sql": "CREATE TABLE t (id BIGINT PRIMARY KEY)" }))
2323            .send()
2324            .await
2325            .unwrap();
2326        // A plain SELECT is NOT audited.
2327        let _ = client
2328            .post(format!("http://{addr}/sql"))
2329            .json(&json!({ "sql": "SELECT 1" }))
2330            .send()
2331            .await
2332            .unwrap();
2333
2334        let body = client
2335            .get(format!("http://{addr}/audit"))
2336            .send()
2337            .await
2338            .unwrap()
2339            .text()
2340            .await
2341            .unwrap();
2342        assert!(
2343            body.contains("\"action\":\"ddl.ok\""),
2344            "audit should record the successful DDL statement: {body}"
2345        );
2346        assert!(
2347            body.contains("CREATE TABLE"),
2348            "audit detail should carry the DDL snippet: {body}"
2349        );
2350        // SELECT must not appear.
2351        assert!(
2352            !body.contains("SELECT 1"),
2353            "non-DDL reads should not be audited: {body}"
2354        );
2355    }
2356
2357    #[tokio::test]
2358    async fn audit_redacts_credential_passwords() {
2359        let dir = tempdir().unwrap();
2360        let db = Arc::new(Database::create(dir.path()).unwrap());
2361        let app = build_app(db);
2362        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2363        let addr = listener.local_addr().unwrap();
2364        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2365        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2366
2367        let client = reqwest::Client::new();
2368        // A CREATE USER carrying a plaintext password must be audited but the
2369        // password must NEVER reach /audit or stderr.
2370        let _ = client
2371            .post(format!("http://{addr}/sql"))
2372            .json(&json!({ "sql": "CREATE USER alice WITH PASSWORD 'topsecret'" }))
2373            .send()
2374            .await
2375            .unwrap();
2376
2377        let body = client
2378            .get(format!("http://{addr}/audit"))
2379            .send()
2380            .await
2381            .unwrap()
2382            .text()
2383            .await
2384            .unwrap();
2385        assert!(
2386            !body.contains("topsecret"),
2387            "password must never appear in the audit log: {body}"
2388        );
2389        assert!(
2390            body.contains("redacted credential statement"),
2391            "credential DDL should be recorded as redacted: {body}"
2392        );
2393    }
2394
2395    fn basic(user: &str, pass: &str) -> String {
2396        let raw = format!("{user}:{pass}");
2397        format!("Basic {}", base64_encode(raw.as_bytes()))
2398    }
2399
2400    fn base64_encode(input: &[u8]) -> String {
2401        const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2402        let mut out = String::new();
2403        let mut buf = 0u32;
2404        let mut bits = 0u32;
2405        for &b in input {
2406            buf = (buf << 8) | b as u32;
2407            bits += 8;
2408            while bits >= 6 {
2409                bits -= 6;
2410                out.push(TABLE[((buf >> bits) & 0x3F) as usize] as char);
2411            }
2412        }
2413        if bits > 0 {
2414            out.push(TABLE[((buf << (6 - bits)) & 0x3F) as usize] as char);
2415        }
2416        while !out.len().is_multiple_of(4) {
2417            out.push('=');
2418        }
2419        out
2420    }
2421}
2422
2423#[cfg(test)]
2424mod session_tests {
2425    use super::*;
2426    use mongreldb_core::Database;
2427    use tempfile::tempdir;
2428
2429    /// Spin up a daemon over a fresh DB with one `items(id int64 pk)` table.
2430    /// Returns the TempDir (must be held alive for the test's duration — dropping
2431    /// it deletes the database directory mid-test) and the bound address.
2432    async fn setup() -> (tempfile::TempDir, std::net::SocketAddr) {
2433        let dir = tempdir().unwrap();
2434        let db = Arc::new(Database::create(dir.path()).unwrap());
2435        let table_schema = mongreldb_core::schema::Schema {
2436            schema_id: 1,
2437            columns: vec![mongreldb_core::schema::ColumnDef {
2438                id: 1,
2439                name: "id".into(),
2440                ty: TypeId::Int64,
2441                flags: mongreldb_core::schema::ColumnFlags::empty()
2442                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
2443                default_value: None,
2444            }],
2445            indexes: vec![],
2446            colocation: vec![],
2447            constraints: Default::default(),
2448            clustered: false,
2449        };
2450        db.create_table("items", table_schema).unwrap();
2451        let app = build_app(db);
2452        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2453        let addr = listener.local_addr().unwrap();
2454        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2455        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2456        (dir, addr)
2457    }
2458
2459    /// Open a session and return its token.
2460    async fn open_session(client: &reqwest::Client, addr: &std::net::SocketAddr) -> String {
2461        let resp = client
2462            .post(format!("http://{addr}/sessions"))
2463            .send()
2464            .await
2465            .unwrap();
2466        assert_eq!(resp.status(), 200);
2467        resp.json::<serde_json::Value>()
2468            .await
2469            .unwrap()
2470            .get("session_id")
2471            .unwrap()
2472            .as_str()
2473            .unwrap()
2474            .to_string()
2475    }
2476
2477    async fn sql_on(
2478        client: &reqwest::Client,
2479        addr: &std::net::SocketAddr,
2480        session: &str,
2481        sql: &str,
2482    ) -> reqwest::Response {
2483        client
2484            .post(format!("http://{addr}/sql"))
2485            .header("X-Session-ID", session)
2486            .json(&json!({ "sql": sql }))
2487            .send()
2488            .await
2489            .unwrap()
2490    }
2491
2492    async fn count_items(client: &reqwest::Client, addr: &std::net::SocketAddr) -> u64 {
2493        client
2494            .get(format!("http://{addr}/tables/items/count"))
2495            .send()
2496            .await
2497            .unwrap()
2498            .json::<serde_json::Value>()
2499            .await
2500            .unwrap()
2501            .get("count")
2502            .unwrap()
2503            .as_u64()
2504            .unwrap()
2505    }
2506
2507    #[tokio::test]
2508    async fn cross_request_transaction_commits() {
2509        let (_dir, addr) = setup().await;
2510        let client = reqwest::Client::new();
2511        let session = open_session(&client, &addr).await;
2512
2513        // BEGIN, INSERT, COMMIT — each its own HTTP request on the same session.
2514        let r = sql_on(&client, &addr, &session, "BEGIN").await;
2515        assert_eq!(r.status(), 200);
2516        let r = sql_on(
2517            &client,
2518            &addr,
2519            &session,
2520            "INSERT INTO items (id) VALUES (1)",
2521        )
2522        .await;
2523        assert_eq!(r.status(), 200, "INSERT should stage successfully");
2524        // Not yet committed → not visible to other connections.
2525        assert_eq!(count_items(&client, &addr).await, 0);
2526        let r = sql_on(&client, &addr, &session, "COMMIT").await;
2527        assert_eq!(r.status(), 200);
2528
2529        // After COMMIT the row is durable and visible.
2530        assert_eq!(count_items(&client, &addr).await, 1);
2531    }
2532
2533    #[tokio::test]
2534    async fn cross_request_transaction_rolls_back() {
2535        let (_dir, addr) = setup().await;
2536        let client = reqwest::Client::new();
2537        let session = open_session(&client, &addr).await;
2538
2539        sql_on(&client, &addr, &session, "BEGIN").await;
2540        sql_on(
2541            &client,
2542            &addr,
2543            &session,
2544            "INSERT INTO items (id) VALUES (5)",
2545        )
2546        .await;
2547        // ROLLBACK discards the staged insert.
2548        let r = sql_on(&client, &addr, &session, "ROLLBACK").await;
2549        assert_eq!(r.status(), 200);
2550        assert_eq!(
2551            count_items(&client, &addr).await,
2552            0,
2553            "rollback discards staged writes"
2554        );
2555    }
2556
2557    #[tokio::test]
2558    async fn unknown_session_id_is_404() {
2559        let (_dir, addr) = setup().await;
2560        let client = reqwest::Client::new();
2561        let resp = client
2562            .post(format!("http://{addr}/sql"))
2563            .header("X-Session-ID", "does-not-exist")
2564            .json(&json!({ "sql": "SELECT 1" }))
2565            .send()
2566            .await
2567            .unwrap();
2568        assert_eq!(resp.status(), 404);
2569    }
2570
2571    #[tokio::test]
2572    async fn close_session_ends_cross_request_state() {
2573        let (_dir, addr) = setup().await;
2574        let client = reqwest::Client::new();
2575        let session = open_session(&client, &addr).await;
2576
2577        // BEGIN on the session, then close it → staged txn is discarded.
2578        sql_on(&client, &addr, &session, "BEGIN").await;
2579        let r = client
2580            .delete(format!("http://{addr}/sessions/{session}"))
2581            .send()
2582            .await
2583            .unwrap();
2584        assert_eq!(r.status(), 200);
2585
2586        // The session token is now invalid.
2587        let resp = sql_on(&client, &addr, &session, "COMMIT").await;
2588        assert_eq!(resp.status(), 404, "closed session is no longer usable");
2589    }
2590
2591    #[tokio::test]
2592    async fn no_session_header_uses_fresh_ephemeral_session() {
2593        // Without X-Session-ID, BEGIN..COMMIT must still work within a single
2594        // multi-statement /sql body (the historical behavior is preserved).
2595        let (_dir, addr) = setup().await;
2596        let client = reqwest::Client::new();
2597        let resp = client
2598            .post(format!("http://{addr}/sql"))
2599            .json(&json!({ "sql": "INSERT INTO items (id) VALUES (42)" }))
2600            .send()
2601            .await
2602            .unwrap();
2603        assert_eq!(resp.status(), 200);
2604        assert_eq!(count_items(&client, &addr).await, 1);
2605    }
2606
2607    #[tokio::test]
2608    async fn prepared_statement_prepare_execute_and_reuse() {
2609        let (_dir, addr) = setup().await;
2610        let client = reqwest::Client::new();
2611        let session = open_session(&client, &addr).await;
2612        sql_on(
2613            &client,
2614            &addr,
2615            &session,
2616            "INSERT INTO items (id) VALUES (1), (2), (3), (4)",
2617        )
2618        .await;
2619
2620        // Prepare a parameterized query once.
2621        let resp = client
2622            .post(format!("http://{addr}/sessions/{session}/prepare"))
2623            .json(&json!({"name":"gt","sql":"SELECT id FROM items WHERE id > $1"}))
2624            .send()
2625            .await
2626            .unwrap();
2627        assert_eq!(resp.status(), 200);
2628
2629        // Execute with param 2 → ids 3,4.
2630        let resp = client
2631            .post(format!("http://{addr}/sessions/{session}/execute"))
2632            .json(&json!({"name":"gt","params":[2]}))
2633            .send()
2634            .await
2635            .unwrap();
2636        assert_eq!(resp.status(), 200);
2637        let body = resp.json::<serde_json::Value>().await.unwrap();
2638        let arr = body
2639            .as_array()
2640            .expect("execute returns a JSON array of rows");
2641        assert_eq!(arr.len(), 2, "ids > 2 are {{3,4}}: {body}");
2642
2643        // Re-execute with a different param → reuses the cached plan, fewer rows.
2644        let resp = client
2645            .post(format!("http://{addr}/sessions/{session}/execute"))
2646            .json(&json!({"name":"gt","params":[3]}))
2647            .send()
2648            .await
2649            .unwrap();
2650        let body = resp.json::<serde_json::Value>().await.unwrap();
2651        assert_eq!(body.as_array().unwrap().len(), 1, "ids > 3 is {{4}}");
2652    }
2653
2654    #[tokio::test]
2655    async fn prepared_statement_deallocate_then_execute_fails() {
2656        let (_dir, addr) = setup().await;
2657        let client = reqwest::Client::new();
2658        let session = open_session(&client, &addr).await;
2659        let _ = client
2660            .post(format!("http://{addr}/sessions/{session}/prepare"))
2661            .json(&json!({"name":"p","sql":"SELECT $1"}))
2662            .send()
2663            .await
2664            .unwrap();
2665        let resp = client
2666            .delete(format!("http://{addr}/sessions/{session}/statements/p"))
2667            .send()
2668            .await
2669            .unwrap();
2670        assert_eq!(resp.status(), 200);
2671        // Execute after deallocate must error.
2672        let resp = client
2673            .post(format!("http://{addr}/sessions/{session}/execute"))
2674            .json(&json!({"name":"p","params":[1]}))
2675            .send()
2676            .await
2677            .unwrap();
2678        assert_ne!(resp.status(), 200, "execute after DEALLOCATE must fail");
2679    }
2680
2681    #[tokio::test]
2682    async fn prepared_statement_rejects_bad_name() {
2683        let (_dir, addr) = setup().await;
2684        let client = reqwest::Client::new();
2685        let session = open_session(&client, &addr).await;
2686        let resp = client
2687            .post(format!("http://{addr}/sessions/{session}/prepare"))
2688            .json(&json!({"name":"1bad","sql":"SELECT 1"}))
2689            .send()
2690            .await
2691            .unwrap();
2692        assert_eq!(
2693            resp.status(),
2694            400,
2695            "statement name starting with a digit must be rejected"
2696        );
2697    }
2698}