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