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