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;
29use serde_json::json;
30
31mod kit;
32mod procedure;
33mod trigger;
34
35/// Map an engine error to the appropriate HTTP status code for defense-in-depth.
36/// Auth errors get 401/403; everything else stays 500. This ensures that even
37/// after the HTTP auth middleware lets a request through, the storage layer's
38/// permission checks surface as the right status (not a generic 500).
39fn status_for_error(e: &mongreldb_core::MongrelError) -> StatusCode {
40    use mongreldb_core::MongrelError;
41    match e {
42        MongrelError::AuthRequired | MongrelError::InvalidCredentials { .. } => {
43            StatusCode::UNAUTHORIZED
44        }
45        MongrelError::AuthNotRequired => StatusCode::BAD_REQUEST,
46        MongrelError::PermissionDenied { .. } => StatusCode::FORBIDDEN,
47        _ => StatusCode::INTERNAL_SERVER_ERROR,
48    }
49}
50
51/// Map a query-layer error (which wraps engine errors via `Core(...)`) to the
52/// appropriate HTTP status code.
53fn status_for_query_error(e: &mongreldb_query::MongrelQueryError) -> StatusCode {
54    use mongreldb_query::MongrelQueryError;
55    match e {
56        MongrelQueryError::Core(core) => status_for_error(core),
57        _ => StatusCode::INTERNAL_SERVER_ERROR,
58    }
59}
60
61struct AppState {
62    db: Arc<Database>,
63    idem: kit::IdempotencyStore,
64    external_modules: Vec<Arc<dyn ExternalTableModule>>,
65    auth_token: Option<String>,
66    /// When true, authenticate via catalog users (HTTP Basic auth).
67    user_auth: bool,
68}
69
70pub fn build_app(db: Arc<Database>) -> axum::Router {
71    build_app_with_config(db, std::iter::empty::<Arc<dyn ExternalTableModule>>(), None, None)
72}
73
74pub fn build_app_with_external_modules(
75    db: Arc<Database>,
76    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
77) -> axum::Router {
78    build_app_with_config(db, external_modules, None, None)
79}
80
81/// Build the daemon router with optional auth token and max-connections limit.
82pub fn build_app_with_config(
83    db: Arc<Database>,
84    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
85    auth_token: Option<String>,
86    max_connections: Option<usize>,
87) -> axum::Router {
88    build_app_full(db, external_modules, auth_token, max_connections, false)
89}
90
91/// Build the daemon router with full auth configuration including user-based auth.
92pub fn build_app_full(
93    db: Arc<Database>,
94    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
95    auth_token: Option<String>,
96    max_connections: Option<usize>,
97    user_auth: bool,
98) -> axum::Router {
99    let state = Arc::new(AppState {
100        idem: kit::IdempotencyStore::new(db.root()),
101        db,
102        external_modules: external_modules.into_iter().collect(),
103        auth_token,
104        user_auth,
105    });
106    let router = axum::Router::new()
107        .route("/health", get(health))
108        .route("/tables", get(list_tables).post(create_table))
109        .route("/tables/{name}", axum::routing::delete(drop_table))
110        .route("/tables/{name}/put", post(put_row))
111        .route("/tables/{name}/count", get(count))
112        .route("/tables/{name}/commit", post(commit))
113        .route("/sql", post(sql))
114        .route("/txn", post(txn))
115        .route("/procedures", get(procedure::list).post(procedure::create))
116        .route(
117            "/procedures/{name}",
118            get(procedure::describe)
119                .put(procedure::replace)
120                .delete(procedure::drop_procedure),
121        )
122        .route("/procedures/{name}/call", post(procedure::call))
123        .route("/triggers", get(trigger::list).post(trigger::create))
124        .route(
125            "/triggers/{name}",
126            get(trigger::describe)
127                .put(trigger::replace)
128                .delete(trigger::drop_trigger),
129        )
130        // Typed Kit-aware surface (authoritative validation + constraints).
131        .route("/kit/schema", get(kit::schema_all))
132        .route("/kit/schema/{table}", get(kit::schema_one))
133        .route("/kit/txn", post(kit::kit_txn))
134        .route("/kit/query", post(kit::kit_query))
135        .route("/kit/create_table", post(kit::kit_create_table))
136        .route("/kit/procedures/{name}/call", post(procedure::kit_call))
137        .route("/compact", post(compact_all))
138        .route("/tables/{name}/compact", post(compact_table))
139        .route("/wal/stream", get(wal_stream))
140        .route("/events", get(events_stream))
141        .with_state(state.clone());
142
143    // Apply auth middleware if token auth or user auth is enabled.
144    let router = if state.auth_token.is_some() || state.user_auth {
145        router.layer(axum::middleware::from_fn_with_state(
146            state.clone(),
147            auth_middleware,
148        ))
149    } else {
150        router
151    };
152
153    // Apply connection limit if configured.
154    if let Some(max) = max_connections {
155        router.layer(tower::limit::ConcurrencyLimitLayer::new(max))
156    } else {
157        router
158    }
159}
160
161/// Auth middleware supporting three modes:
162/// 1. **Token** (`--auth-token <token>`): checks `Authorization: Bearer <token>`.
163/// 2. **User auth** (`--auth-users`): checks `Authorization: Basic <base64(user:pass)>`
164///    against catalog users (Argon2id-verified). Injects a `Principal` into
165///    request extensions.
166/// 3. **Both**: token OR valid user credentials accepted.
167async fn auth_middleware(
168    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
169    mut req: axum::extract::Request,
170    next: axum::middleware::Next,
171) -> Result<axum::response::Response, axum::http::StatusCode> {
172    let header = req
173        .headers()
174        .get("authorization")
175        .and_then(|v| v.to_str().ok())
176        .unwrap_or("");
177
178    // Mode 1: Token auth (Bearer).
179    if let Some(token) = &state.auth_token {
180        if let Some(provided) = header.strip_prefix("Bearer ") {
181            if provided == token {
182                return Ok(next.run(req).await);
183            }
184        }
185    }
186
187    // Mode 2: User auth (Basic).
188    if state.user_auth {
189        if let Some(encoded) = header.strip_prefix("Basic ") {
190            if let Ok(decoded) = base64_decode(encoded) {
191                if let Ok(creds) = std::str::from_utf8(&decoded) {
192                    if let Some((username, password)) = creds.split_once(':') {
193                        if let Ok(Some(_user)) = state.db.verify_user(username, password) {
194                            // Inject the principal for permission checks.
195                            if let Some(principal) = state.db.resolve_principal(username) {
196                                req.extensions_mut().insert(principal);
197                            }
198                            return Ok(next.run(req).await);
199                        }
200                    }
201                }
202            }
203        }
204    }
205
206    Err(axum::http::StatusCode::UNAUTHORIZED)
207}
208
209/// Minimal Base64 decoder (no extra dep).
210fn base64_decode(input: &str) -> Result<Vec<u8>, ()> {
211    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
212    let input: Vec<u8> = input.bytes().filter(|&b| b != b'\n' && b != b'\r' && b != b' ').collect();
213    let mut out = Vec::with_capacity(input.len() * 3 / 4);
214    let mut buf = 0u32;
215    let mut bits = 0u32;
216    for &b in &input {
217        if b == b'=' {
218            break;
219        }
220        let val = TABLE.iter().position(|&t| t == b).ok_or(())? as u32;
221        buf = (buf << 6) | val;
222        bits += 6;
223        if bits >= 8 {
224            bits -= 8;
225            out.push((buf >> bits) as u8);
226        }
227    }
228    Ok(out)
229}
230
231/// `GET /wal/stream?since=<seq>` — stream committed WAL records as
232/// newline-delimited JSON for replication followers. Each line is a JSON
233/// object `{ "seq": N, "txn_id": N, "op": {...} }`.
234async fn wal_stream(
235    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
236    axum::extract::Query(params): axum::extract::Query<WalStreamParams>,
237) -> Result<Response, StatusCode> {
238    let db_root = state.db.root().to_path_buf();
239    let since = params.since.unwrap_or(0);
240
241    // Read all committed WAL records with seq > since and stream as NDJSON.
242    let body = tokio::task::spawn_blocking(move || -> Result<String, String> {
243        let records = mongreldb_core::wal::SharedWal::replay(&db_root).map_err(|e| e.to_string())?;
244        let mut out = String::new();
245        for record in records.iter().filter(|r| r.seq.0 > since) {
246            if let Ok(json) = serde_json::to_string(record) {
247                out.push_str(&json);
248                out.push('\n');
249            }
250        }
251        Ok(out)
252    })
253    .await
254    .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
255
256    let body = body.map_err(|e| {
257        eprintln!("wal_stream error: {e}");
258        StatusCode::INTERNAL_SERVER_ERROR
259    })?;
260
261    Ok((
262        [
263            (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
264            (header::CACHE_CONTROL, "no-cache".to_string()),
265        ],
266        body,
267    )
268        .into_response())
269}
270
271#[derive(serde::Deserialize)]
272struct WalStreamParams {
273    since: Option<u64>,
274}
275
276/// `GET /events` — stream change-data-capture events (from NOTIFY/LISTEN and
277/// committed Put/Delete operations) as newline-delimited JSON. Each line is
278/// a JSON `ChangeEvent { channel, table, op, epoch, message }`.
279async fn events_stream(
280    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
281) -> Result<Response, StatusCode> {
282    let mut rx = state.db.subscribe_changes();
283    let body = tokio::task::spawn_blocking(move || {
284        // Drain the current backlog (non-blocking).
285        let mut out = String::new();
286        while let Ok(event) = rx.try_recv() {
287            if let Ok(json) = serde_json::to_string(&event) {
288                out.push_str(&json);
289                out.push('\n');
290            }
291        }
292        out
293    })
294    .await
295    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
296
297    Ok((
298        [
299            (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
300            (header::CACHE_CONTROL, "no-cache".to_string()),
301        ],
302        body,
303    )
304        .into_response())
305}
306
307/// Launch the §5.9 background auto-compaction sweep (run-count cost trigger).
308/// One OS thread, sleeping `interval` between sweeps; each tick locks each
309/// table individually and calls `Table::maybe_compact`. Best-effort: a
310/// compaction error is logged and never aborts the sweep.
311pub fn spawn_auto_compactor(db: Arc<Database>) {
312    std::thread::Builder::new()
313        .name("mongreldb-auto-compact".into())
314        .spawn(move || loop {
315            std::thread::sleep(std::time::Duration::from_secs(30));
316            for name in db.table_names() {
317                let Ok(handle) = db.table(&name) else {
318                    continue;
319                };
320                let mut t = handle.lock();
321                let before = t.run_count();
322                match t.maybe_compact() {
323                    Ok(true) => {
324                        eprintln!(
325                            "[auto-compact] {name}: {} runs -> {}",
326                            before,
327                            t.run_count()
328                        );
329                    }
330                    Ok(false) => {}
331                    Err(e) => {
332                        eprintln!("[auto-compact] {name}: compaction failed: {e}");
333                    }
334                }
335            }
336        })
337        .expect("spawn auto-compact thread");
338}
339
340async fn health() -> StatusCode {
341    StatusCode::OK
342}
343
344/// `POST /compact` — compact all mounted tables.
345async fn compact_all(State(state): State<Arc<AppState>>) -> (StatusCode, Json<serde_json::Value>) {
346    match state.db.compact() {
347        Ok((compacted, skipped)) => (
348            StatusCode::OK,
349            Json(json!({
350                "status": "ok",
351                "compacted": compacted,
352                "skipped": skipped,
353            })),
354        ),
355        Err(e) => (
356            StatusCode::INTERNAL_SERVER_ERROR,
357            Json(json!({ "status": "error", "message": format!("{e}") })),
358        ),
359    }
360}
361
362/// `POST /tables/{name}/compact` — compact a single table.
363async fn compact_table(
364    State(state): State<Arc<AppState>>,
365    Path(name): Path<String>,
366) -> (StatusCode, Json<serde_json::Value>) {
367    match state.db.compact_table(&name) {
368        Ok(true) => (
369            StatusCode::OK,
370            Json(json!({ "status": "compacted", "table": name })),
371        ),
372        Ok(false) => (
373            StatusCode::OK,
374            Json(json!({ "status": "skipped", "table": name, "reason": "fewer than 2 runs" })),
375        ),
376        Err(e) => (
377            StatusCode::INTERNAL_SERVER_ERROR,
378            Json(json!({ "status": "error", "table": name, "message": format!("{e}") })),
379        ),
380    }
381}
382
383#[derive(Deserialize)]
384struct CreateTableRequest {
385    name: String,
386    columns: Vec<ColumnDefJson>,
387}
388
389#[derive(Deserialize)]
390struct ColumnDefJson {
391    id: u16,
392    name: String,
393    ty: String,
394    primary_key: bool,
395    #[serde(default)]
396    nullable: bool,
397}
398
399async fn create_table(
400    State(state): State<Arc<AppState>>,
401    Json(req): Json<CreateTableRequest>,
402) -> Response {
403    let mut columns = Vec::new();
404    for c in &req.columns {
405        let ty = match c.ty.as_str() {
406            "int64" | "bigint" => TypeId::Int64,
407            "float64" | "double" => TypeId::Float64,
408            "bytes" | "varchar" | "text" => TypeId::Bytes,
409            "bool" => TypeId::Bool,
410            other => {
411                return (StatusCode::BAD_REQUEST, format!("unknown type: {other}")).into_response()
412            }
413        };
414        let mut flags = mongreldb_core::schema::ColumnFlags::empty();
415        if c.primary_key {
416            flags = flags.with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY);
417        }
418        if c.nullable {
419            flags = flags.with(mongreldb_core::schema::ColumnFlags::NULLABLE);
420        }
421        columns.push(mongreldb_core::schema::ColumnDef {
422            id: c.id,
423            name: c.name.clone(),
424            ty,
425            flags,
426        });
427    }
428    let schema = Schema {
429        schema_id: 0,
430        columns,
431        indexes: vec![],
432        colocation: vec![],
433        constraints: Default::default(),
434        clustered: false,
435    };
436    if let Err(msg) = validate_table_name(&req.name) {
437        return (StatusCode::BAD_REQUEST, msg).into_response();
438    }
439    match state.db.create_table(&req.name, schema) {
440        Ok(id) => Json(json!({ "table_id": id })).into_response(),
441        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
442    }
443}
444
445async fn list_tables(State(state): State<Arc<AppState>>) -> Json<Vec<String>> {
446    Json(state.db.table_names())
447}
448
449async fn drop_table(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
450    match state.db.drop_table(&name) {
451        Ok(_) => StatusCode::OK.into_response(),
452        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
453    }
454}
455
456#[derive(Deserialize)]
457struct PutRequest {
458    row: Vec<serde_json::Value>,
459}
460
461pub(crate) fn json_to_value(v: &serde_json::Value, expected: TypeId) -> Value {
462    match (v, expected) {
463        (serde_json::Value::Number(n), TypeId::Float64) => {
464            n.as_f64().map(Value::Float64).unwrap_or(Value::Null)
465        }
466        (serde_json::Value::Number(n), TypeId::Int64) => {
467            n.as_i64().map(Value::Int64).unwrap_or(Value::Null)
468        }
469        (serde_json::Value::String(s), TypeId::Bytes) => Value::Bytes(s.as_bytes().to_vec()),
470        (serde_json::Value::Bool(b), TypeId::Bool) => Value::Bool(*b),
471        (serde_json::Value::Null, _) => Value::Null,
472        // Lenient fallbacks for unknown/loosely-typed JSON.
473        (serde_json::Value::Number(n), _) => {
474            if let Some(i) = n.as_i64() {
475                Value::Int64(i)
476            } else if let Some(f) = n.as_f64() {
477                Value::Float64(f)
478            } else {
479                Value::Null
480            }
481        }
482        (serde_json::Value::String(s), _) => Value::Bytes(s.as_bytes().to_vec()),
483        (serde_json::Value::Bool(b), _) => Value::Bool(*b),
484        _ => Value::Null,
485    }
486}
487
488/// Parse a flat JSON array `[col_id, val, col_id, val, ...]` into typed cell
489/// pairs, validating the schema. Returns `Err(message)` on any malformed pair.
490fn parse_cells(
491    row: &[serde_json::Value],
492    schema: &mongreldb_core::schema::Schema,
493) -> Result<Vec<(u16, Value)>, String> {
494    if row.len() & 1 != 0 {
495        return Err("row must be an even-length array of [col_id, value] pairs".into());
496    }
497    let mut out = Vec::with_capacity(row.len() / 2);
498    for chunk in row.chunks(2) {
499        let col_id = chunk[0]
500            .as_u64()
501            .ok_or("column id must be a non-negative integer")? as u16;
502        let expected = schema
503            .columns
504            .iter()
505            .find(|c| c.id == col_id)
506            .map(|c| c.ty)
507            .ok_or_else(|| format!("unknown column id {col_id}"))?;
508        let val = json_to_value(&chunk[1], expected);
509        out.push((col_id, val));
510    }
511    Ok(out)
512}
513
514/// Basic validation for a table name: non-empty and no path separators.
515pub(crate) fn validate_table_name(name: &str) -> Result<(), String> {
516    if name.is_empty() {
517        return Err("table name must not be empty".into());
518    }
519    if name.contains('/') || name.contains('\\') || name.contains('\0') {
520        return Err("table name contains invalid characters".into());
521    }
522    Ok(())
523}
524
525async fn put_row(
526    State(state): State<Arc<AppState>>,
527    Path(name): Path<String>,
528    Json(req): Json<PutRequest>,
529) -> Response {
530    let handle = match state.db.table(&name) {
531        Ok(h) => h,
532        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
533    };
534    let mut g = handle.lock();
535    let schema = g.schema().clone();
536    let row = match parse_cells(&req.row, &schema) {
537        Ok(r) => r,
538        Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
539    };
540    match g.put(row) {
541        Ok(rid) => Json(json!({ "row_id": rid.0.to_string() })).into_response(),
542        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
543    }
544}
545
546async fn count(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
547    let handle = match state.db.table(&name) {
548        Ok(h) => h,
549        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
550    };
551    Json(json!({ "count": handle.lock().count() })).into_response()
552}
553
554async fn commit(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
555    let handle = match state.db.table(&name) {
556        Ok(h) => h,
557        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
558    };
559    let mut g = handle.lock();
560    match g.commit() {
561        Ok(epoch) => Json(json!({ "epoch": epoch.0 })).into_response(),
562        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
563    }
564}
565
566#[derive(Deserialize)]
567struct SqlRequest {
568    sql: String,
569}
570
571async fn sql(State(state): State<Arc<AppState>>, Json(req): Json<SqlRequest>) -> Response {
572    let session = match MongrelSession::open_with_external_modules(
573        Arc::clone(&state.db),
574        state.external_modules.iter().cloned(),
575    ) {
576        Ok(s) => s,
577        Err(e) => return (status_for_query_error(&e), e.to_string()).into_response(),
578    };
579    match session.run(&req.sql).await {
580        Ok(batches) => {
581            if batches.is_empty() {
582                return StatusCode::OK.into_response();
583            }
584            let schema = batches[0].schema();
585            let mut buf = Vec::new();
586            let mut writer =
587                arrow::ipc::writer::FileWriter::try_new(&mut buf, schema.as_ref()).unwrap();
588            for b in &batches {
589                let _ = writer.write(b);
590            }
591            let _ = writer.finish();
592            drop(writer);
593            (
594                [(header::CONTENT_TYPE, "application/vnd.apache.arrow.file")],
595                buf,
596            )
597                .into_response()
598        }
599        Err(e) => (status_for_query_error(&e), e.to_string()).into_response(),
600    }
601}
602
603#[derive(Deserialize)]
604struct TxnOp {
605    table: String,
606    op: String,
607    cells: Option<Vec<serde_json::Value>>,
608    row_id: Option<u64>,
609}
610
611#[derive(Deserialize)]
612struct TxnRequest {
613    ops: Vec<TxnOp>,
614}
615
616async fn txn(State(state): State<Arc<AppState>>, Json(req): Json<TxnRequest>) -> Response {
617    // Pre-validate every op against the live schemas before entering the
618    // transaction, so malformed input returns 400 without consuming an epoch
619    // or poisoning a txn.
620    let mut parsed: Vec<(String, TxnAction)> = Vec::with_capacity(req.ops.len());
621    for op in &req.ops {
622        match op.op.as_str() {
623            "put" => {
624                let cells_json = match op.cells.as_ref() {
625                    Some(c) if !c.is_empty() => c,
626                    _ => {
627                        return (StatusCode::BAD_REQUEST, "put op requires non-empty cells")
628                            .into_response()
629                    }
630                };
631                let handle = match state.db.table(&op.table) {
632                    Ok(h) => h,
633                    Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
634                };
635                let schema = handle.lock().schema().clone();
636                let cells = match parse_cells(cells_json, &schema) {
637                    Ok(c) => c,
638                    Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
639                };
640                parsed.push((op.table.clone(), TxnAction::Put(cells)));
641            }
642            "delete" => {
643                let rid = match op.row_id {
644                    Some(r) => r,
645                    None => {
646                        return (StatusCode::BAD_REQUEST, "delete op requires row_id")
647                            .into_response()
648                    }
649                };
650                parsed.push((op.table.clone(), TxnAction::Delete(rid)));
651            }
652            other => {
653                return (StatusCode::BAD_REQUEST, format!("unknown op: {other}")).into_response()
654            }
655        }
656    }
657
658    let result = state.db.transaction(|t| {
659        for (table, action) in &parsed {
660            match action {
661                TxnAction::Put(cells) => {
662                    t.put(table, cells.clone())?;
663                }
664                TxnAction::Delete(rid) => {
665                    t.delete(table, mongreldb_core::RowId(*rid))?;
666                }
667            }
668        }
669        Ok(())
670    });
671    match result {
672        Ok(_) => Json(json!({ "status": "committed" })).into_response(),
673        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
674    }
675}
676
677enum TxnAction {
678    Put(Vec<(u16, Value)>),
679    Delete(u64),
680}
681
682#[cfg(test)]
683mod auth_tests {
684    use super::*;
685    use mongreldb_core::Database;
686    use tempfile::tempdir;
687
688    #[tokio::test]
689    async fn auth_rejects_missing_token() {
690        let dir = tempdir().unwrap();
691        let db = Arc::new(Database::create(dir.path()).unwrap());
692        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
693        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
694        let addr = listener.local_addr().unwrap();
695        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
696        // Give the server a moment to start.
697        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
698
699        let client = reqwest::Client::new();
700        let resp = client
701            .get(format!("http://{addr}/health"))
702            .send()
703            .await
704            .unwrap();
705        assert_eq!(resp.status(), 401);
706    }
707
708    #[tokio::test]
709    async fn auth_accepts_valid_token() {
710        let dir = tempdir().unwrap();
711        let db = Arc::new(Database::create(dir.path()).unwrap());
712        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
713        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
714        let addr = listener.local_addr().unwrap();
715        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
716        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
717
718        let client = reqwest::Client::new();
719        let resp = client
720            .get(format!("http://{addr}/health"))
721            .header("Authorization", "Bearer secret")
722            .send()
723            .await
724            .unwrap();
725        assert_eq!(resp.status(), 200);
726    }
727
728    #[tokio::test]
729    async fn no_auth_when_token_unset() {
730        let dir = tempdir().unwrap();
731        let db = Arc::new(Database::create(dir.path()).unwrap());
732        let app = build_app_with_config(db, std::iter::empty(), None, None);
733        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
734        let addr = listener.local_addr().unwrap();
735        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
736        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
737
738        let client = reqwest::Client::new();
739        let resp = client
740            .get(format!("http://{addr}/health"))
741            .send()
742            .await
743            .unwrap();
744        assert_eq!(resp.status(), 200);
745    }
746}
747
748#[cfg(test)]
749mod wal_stream_tests {
750    use super::*;
751    use mongreldb_core::Database;
752    use tempfile::tempdir;
753
754    #[tokio::test]
755    async fn wal_stream_returns_records_after_commit() {
756        let dir = tempdir().unwrap();
757        let db = Arc::new(Database::create(dir.path()).unwrap());
758        let table_schema = mongreldb_core::schema::Schema {
759            schema_id: 1,
760            columns: vec![mongreldb_core::schema::ColumnDef {
761                id: 1,
762                name: "id".into(),
763                ty: TypeId::Int64,
764                flags: mongreldb_core::schema::ColumnFlags::empty()
765                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
766            }],
767            indexes: vec![],
768            colocation: vec![],
769            constraints: Default::default(),
770            clustered: false,
771        };
772        db.create_table("items", table_schema).unwrap();
773        // Write a row to generate WAL records.
774        let handle = db.table("items").unwrap();
775        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
776        handle.lock().flush().unwrap();
777
778        let app = build_app(db);
779        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
780        let addr = listener.local_addr().unwrap();
781        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
782        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
783
784        let resp = reqwest::get(format!("http://{addr}/wal/stream")).await.unwrap();
785        assert_eq!(resp.status(), 200);
786        let body = resp.text().await.unwrap();
787        // Should contain at least one record (the flush commit).
788        assert!(!body.is_empty(), "wal_stream should return records");
789        assert!(body.contains("seq"), "response should contain seq field");
790    }
791}