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