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