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