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