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