Skip to main content

mentedb_server/
hosting.rs

1//! Endpoints and middleware for running the server as a shared, multi tenant
2//! service:
3//!
4//! Per database (mounted under `/v1` and `/v1/db/{db}`):
5//!
6//! - `GET /memories`: a paged, token scoped list, narrowed by tag through the
7//!   bitmap index, in a stable order.
8//! - `PATCH /memories/{id}`: update attributes, tags and `valid_until` in
9//!   place, keeping the id.
10//! - `POST /memories/{id}/supersede`: replace a memory with a new one, closing
11//!   the old one and linking the two with a Supersedes edge.
12//! - `POST /recall_for_action`: memories that bear on an action about to
13//!   happen.
14//! - `GET /consolidation_candidates`: clusters of near duplicates (read only).
15//! - `POST /consolidate`: merge exactly the given memories.
16//!
17//! Global:
18//!
19//! - `DELETE /v1/db/{db}`: drop a database (admin, or a maintenance token for
20//!   it).
21//! - `POST /v1/batch/search`: one query across several databases.
22//! - `GET /v1/live`: liveness, touching nothing.
23//!
24//! Middleware: request deadlines (`x-mentedb-deadline-ms` and
25//! `MENTEDB_REQUEST_TIMEOUT_MS`). Also the bounded enrichment worker pool.
26
27use std::collections::HashMap;
28use std::future::Future;
29use std::sync::{Arc, OnceLock};
30use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
31
32use axum::body::Body;
33use axum::extract::{Path, Query, State};
34use axum::http::{HeaderMap, Request, StatusCode};
35use axum::middleware::Next;
36use axum::response::{IntoResponse, Response};
37use axum::routing::{get, post};
38use axum::{Extension, Json, Router};
39use mentedb::injection::InjectionQuery;
40use mentedb::{HybridOptions, MemoryPatch};
41use mentedb_core::MemoryNode;
42use mentedb_core::error::MenteError;
43use mentedb_core::types::{AgentId, MemoryId, UserId};
44use serde_json::{Value, json};
45use uuid::Uuid;
46
47use crate::auth::AuthenticatedAgent;
48use crate::databases::{DEFAULT_DB, Db, validate_name};
49use crate::error::ApiError;
50use crate::handlers::{
51    check_body_agent, check_owner, embed_query, memory_id_param, memory_node_to_json,
52    memory_node_to_json_with, parse_attributes, parse_embedding, parse_memory_type,
53    parse_optional_uuid, wants_embedding,
54};
55use crate::state::AppState;
56
57type Caller = Option<Extension<AuthenticatedAgent>>;
58
59// ---------------------------------------------------------------------------
60// Configuration
61// ---------------------------------------------------------------------------
62
63/// Settings for the hosting endpoints and middleware.
64#[derive(Clone, Debug)]
65pub struct HostingConfig {
66    /// Page size of `GET /memories` when `limit` is not given.
67    pub list_default_limit: usize,
68    /// Largest page `GET /memories` returns (`MENTEDB_LIST_MAX_LIMIT`).
69    pub list_max_limit: usize,
70    /// Most databases one batch search may name (`MENTEDB_BATCH_MAX_DATABASES`).
71    pub batch_max_databases: usize,
72    /// Results per database when a batch search gives no `k`.
73    pub batch_default_k: usize,
74    /// Most results per database one batch search may ask for.
75    pub batch_max_k: usize,
76    /// Results of recall_for_action when no `k` is given.
77    pub action_default_k: usize,
78    /// Most results one recall_for_action may ask for.
79    pub action_max_k: usize,
80    /// Upper bound on every request (`MENTEDB_REQUEST_TIMEOUT_MS`); a
81    /// request's own deadline header can only shorten it. `None`: no bound.
82    pub request_timeout: Option<Duration>,
83    /// Threads in the enrichment pool, which is also the most enrichment runs
84    /// at once (`MENTEDB_ENRICH_WORKERS`).
85    pub enrich_workers: usize,
86    /// Named database `/v1/ready` opens (and so locks) to prove databases can
87    /// be opened (`MENTEDB_READY_PROBE_DB`; empty turns the check off).
88    pub ready_probe_db: Option<String>,
89}
90
91impl Default for HostingConfig {
92    fn default() -> Self {
93        Self {
94            list_default_limit: 50,
95            list_max_limit: 1000,
96            batch_max_databases: 32,
97            batch_default_k: 10,
98            batch_max_k: 200,
99            action_default_k: 10,
100            action_max_k: 100,
101            request_timeout: None,
102            enrich_workers: 1,
103            ready_probe_db: Some("readiness-probe".to_string()),
104        }
105    }
106}
107
108fn env_usize(name: &str) -> Option<usize> {
109    std::env::var(name).ok().and_then(|v| v.trim().parse().ok())
110}
111
112impl HostingConfig {
113    /// Defaults overridden by the environment.
114    pub fn from_env() -> Self {
115        let mut c = Self::default();
116        if let Some(n) = env_usize("MENTEDB_LIST_MAX_LIMIT").filter(|n| *n > 0) {
117            c.list_max_limit = n;
118        }
119        if let Some(n) = env_usize("MENTEDB_BATCH_MAX_DATABASES").filter(|n| *n > 0) {
120            c.batch_max_databases = n;
121        }
122        if let Some(n) = env_usize("MENTEDB_REQUEST_TIMEOUT_MS") {
123            c.request_timeout = (n > 0).then(|| Duration::from_millis(n as u64));
124        }
125        if let Some(n) = env_usize("MENTEDB_ENRICH_WORKERS").filter(|n| *n > 0) {
126            c.enrich_workers = n;
127        }
128        if let Ok(v) = std::env::var("MENTEDB_READY_PROBE_DB") {
129            let v = v.trim().to_string();
130            c.ready_probe_db = (!v.is_empty()).then_some(v);
131        }
132        c
133    }
134}
135
136/// The hosting settings, read from the environment once.
137pub fn hosting_config() -> &'static HostingConfig {
138    static CONFIG: OnceLock<HostingConfig> = OnceLock::new();
139    CONFIG.get_or_init(HostingConfig::from_env)
140}
141
142// ---------------------------------------------------------------------------
143// Routes
144// ---------------------------------------------------------------------------
145
146/// Routes on one database that are new paths. `GET /memories` and
147/// `PATCH /memories/{id}` are added to the existing paths in `routes.rs`.
148pub fn routes() -> Router<Arc<AppState>> {
149    Router::new()
150        .route("/memories/{id}/supersede", post(supersede_memory))
151        .route("/recall_for_action", post(recall_for_action))
152        .route("/consolidation_candidates", get(consolidation_candidates))
153        .route("/consolidate", post(consolidate))
154}
155
156/// Routes outside any one database.
157pub fn global_routes() -> Router<Arc<AppState>> {
158    Router::new()
159        .route("/v1/db/{db}", axum::routing::delete(drop_database))
160        .route("/v1/batch/search", post(batch_search))
161        .route("/v1/live", get(live))
162}
163
164// ---------------------------------------------------------------------------
165// Deadlines
166// ---------------------------------------------------------------------------
167
168/// Header carrying the caller's budget for a request, in milliseconds.
169pub const DEADLINE_HEADER: &str = "x-mentedb-deadline-ms";
170
171/// When the current request must be answered by. Long handlers check it
172/// between steps and stop with 504 once it has passed.
173#[derive(Clone, Copy, Debug)]
174pub struct Deadline(pub Instant);
175
176impl Deadline {
177    /// 504 once the deadline has passed.
178    pub fn check(&self) -> Result<(), ApiError> {
179        if Instant::now() >= self.0 {
180            Err(ApiError::GatewayTimeout(
181                "the request deadline passed before the work finished".into(),
182            ))
183        } else {
184            Ok(())
185        }
186    }
187}
188
189fn check_deadline(deadline: &Option<Extension<Deadline>>) -> Result<(), ApiError> {
190    match deadline {
191        Some(Extension(d)) => d.check(),
192        None => Ok(()),
193    }
194}
195
196/// Bound every request by the smaller of the server's request timeout (the
197/// middleware state) and the caller's `x-mentedb-deadline-ms`. Past it the
198/// request is answered with 504; the handler sees the [`Deadline`] and its
199/// long loops stop early. A malformed header is a 400.
200pub async fn deadline_middleware(
201    State(server_timeout): State<Option<Duration>>,
202    mut request: Request<Body>,
203    next: Next,
204) -> Response {
205    let requested = match request.headers().get(DEADLINE_HEADER) {
206        None => None,
207        Some(v) => match v.to_str().ok().and_then(|s| s.trim().parse::<u64>().ok()) {
208            Some(ms) => Some(Duration::from_millis(ms)),
209            None => {
210                return ApiError::BadRequest(format!(
211                    "{DEADLINE_HEADER} must be a whole number of milliseconds"
212                ))
213                .into_response();
214            }
215        },
216    };
217    let budget = match (requested, server_timeout) {
218        (Some(a), Some(b)) => Some(a.min(b)),
219        (a, b) => a.or(b),
220    };
221    let Some(budget) = budget else {
222        return next.run(request).await;
223    };
224    request
225        .extensions_mut()
226        .insert(Deadline(Instant::now() + budget));
227    match tokio::time::timeout(budget, next.run(request)).await {
228        Ok(response) => response,
229        Err(_) => ApiError::GatewayTimeout(format!("no answer within {} ms", budget.as_millis()))
230            .into_response(),
231    }
232}
233
234// ---------------------------------------------------------------------------
235// Helpers
236// ---------------------------------------------------------------------------
237
238fn now_micros() -> u64 {
239    SystemTime::now()
240        .duration_since(UNIX_EPOCH)
241        .unwrap_or_default()
242        .as_micros() as u64
243}
244
245async fn blocking<T, F>(what: &'static str, f: F) -> Result<T, ApiError>
246where
247    T: Send + 'static,
248    F: FnOnce() -> Result<T, ApiError> + Send + 'static,
249{
250    tokio::task::spawn_blocking(f)
251        .await
252        .map_err(|e| ApiError::Internal(format!("{what} task failed: {e}")))?
253}
254
255fn engine(what: &'static str) -> impl FnOnce(MenteError) -> ApiError {
256    move |e| match e {
257        MenteError::MemoryNotFound(id) => ApiError::NotFound(format!("memory {id} not found")),
258        other => ApiError::Internal(format!("{what} failed: {other}")),
259    }
260}
261
262/// The agent a token is bound to, unless it is an admin token.
263fn token_agent(agent: &Caller) -> Result<Option<AgentId>, ApiError> {
264    match agent {
265        Some(Extension(a)) if !a.admin => a.agent(),
266        _ => Ok(None),
267    }
268}
269
270/// The agent a read is scoped to: the requested one (which must match a token
271/// bound to an agent), else the token's agent, else none.
272fn scope_agent(agent: &Caller, requested: Option<Uuid>) -> Result<Option<AgentId>, ApiError> {
273    match requested {
274        Some(id) => {
275            check_body_agent(agent, AgentId(id))?;
276            Ok(Some(AgentId(id)))
277        }
278        None => token_agent(agent),
279    }
280}
281
282fn visible(owner: Option<AgentId>, node: &MemoryNode) -> bool {
283    owner.is_none_or(|a| node.agent_id == a || node.agent_id.is_nil())
284}
285
286fn bounded(value: Option<usize>, default: usize, max: usize) -> usize {
287    value.unwrap_or(default).clamp(1, max.max(1))
288}
289
290fn body_usize(req: &Value, field: &str) -> Result<Option<usize>, ApiError> {
291    req.get(field)
292        .filter(|v| !v.is_null())
293        .map(|v| {
294            v.as_u64()
295                .map(|n| n as usize)
296                .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must be a whole number")))
297        })
298        .transpose()
299}
300
301fn query_usize(q: &HashMap<String, String>, field: &str) -> Result<Option<usize>, ApiError> {
302    q.get(field)
303        .map(|s| {
304            s.trim()
305                .parse()
306                .map_err(|_| ApiError::BadRequest(format!("'{field}' must be a whole number")))
307        })
308        .transpose()
309}
310
311fn query_bool(q: &HashMap<String, String>, field: &str) -> Result<bool, ApiError> {
312    match q.get(field).map(|s| s.trim().to_lowercase()) {
313        None => Ok(false),
314        Some(v) if v == "1" || v == "true" => Ok(true),
315        Some(v) if v == "0" || v == "false" || v.is_empty() => Ok(false),
316        Some(_) => Err(ApiError::BadRequest(format!("'{field}' must be 0 or 1"))),
317    }
318}
319
320fn string_array(req: &Value, field: &str) -> Result<Vec<String>, ApiError> {
321    match req.get(field) {
322        None | Some(Value::Null) => Ok(Vec::new()),
323        Some(Value::Array(items)) => items
324            .iter()
325            .map(|v| {
326                v.as_str()
327                    .map(String::from)
328                    .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must hold strings")))
329            })
330            .collect(),
331        Some(_) => Err(ApiError::BadRequest(format!("'{field}' must be an array"))),
332    }
333}
334
335fn load_owned(agent: &Caller, db: &Db, id: MemoryId) -> Result<MemoryNode, ApiError> {
336    let node = db
337        .get_memory(id)
338        .map_err(|_| ApiError::NotFound(format!("memory {id} not found")))?;
339    check_owner(agent, &node)?;
340    Ok(node)
341}
342
343// ---------------------------------------------------------------------------
344// GET /memories
345// ---------------------------------------------------------------------------
346
347/// A page of the caller's memories. Query: `limit`, `offset`, `tag`,
348/// `include_inactive` (0 or 1), `fields=embedding`. A token bound to an agent
349/// sees its own memories and shared ones owned by no agent.
350pub async fn list_memories(
351    agent: Caller,
352    deadline: Option<Extension<Deadline>>,
353    db: Db,
354    Query(q): Query<HashMap<String, String>>,
355) -> Result<Json<Value>, ApiError> {
356    let cfg = hosting_config();
357    let limit = bounded(
358        query_usize(&q, "limit")?,
359        cfg.list_default_limit,
360        cfg.list_max_limit,
361    );
362    let offset = query_usize(&q, "offset")?.unwrap_or(0);
363    let include_inactive = query_bool(&q, "include_inactive")?;
364    let tag = q.get("tag").filter(|t| !t.is_empty()).cloned();
365    let owner = token_agent(&agent)?;
366    let embedding = wants_embedding(&q);
367    check_deadline(&deadline)?;
368    let handle = db.handle.clone();
369    let page = blocking("list memories", move || {
370        Ok(handle.list_memories_page(
371            tag.as_deref(),
372            owner,
373            include_inactive,
374            now_micros(),
375            offset,
376            limit,
377        ))
378    })
379    .await?;
380    check_deadline(&deadline)?;
381    let memories: Vec<Value> = page
382        .memories
383        .iter()
384        .map(|n| memory_node_to_json_with(n, embedding))
385        .collect();
386    let next_offset = page.has_more.then_some(offset + memories.len());
387    Ok(Json(json!({
388        "database": db.name,
389        "limit": limit,
390        "offset": offset,
391        "next_offset": next_offset,
392        "memories": memories,
393    })))
394}
395
396// ---------------------------------------------------------------------------
397// PATCH /memories/{id}
398// ---------------------------------------------------------------------------
399
400/// Update a memory in place. Body: `attributes` (merged), `tags` (replaces),
401/// `valid_until` (microseconds, or null to clear). The id is kept.
402pub async fn patch_memory(
403    agent: Caller,
404    db: Db,
405    Path(params): Path<HashMap<String, String>>,
406    Json(req): Json<Value>,
407) -> Result<Json<Value>, ApiError> {
408    let id = memory_id_param(&params)?;
409    load_owned(&agent, &db, id)?;
410    let mut patch = MemoryPatch::default();
411    match req.get("attributes") {
412        None | Some(Value::Null) => {}
413        Some(v @ Value::Object(_)) => patch.attributes = parse_attributes(Some(v)),
414        Some(_) => {
415            return Err(ApiError::BadRequest(
416                "'attributes' must be an object".into(),
417            ));
418        }
419    }
420    if req.get("tags").is_some_and(|v| !v.is_null()) {
421        patch.tags = Some(string_array(&req, "tags")?);
422    }
423    if let Some(v) = req.get("valid_until") {
424        patch.valid_until = Some(match v {
425            Value::Null => None,
426            v => Some(v.as_u64().ok_or_else(|| {
427                ApiError::BadRequest("'valid_until' must be microseconds or null".into())
428            })?),
429        });
430    }
431    if patch.attributes.is_empty() && patch.tags.is_none() && patch.valid_until.is_none() {
432        return Err(ApiError::BadRequest(
433            "nothing to update: send attributes, tags or valid_until".into(),
434        ));
435    }
436    let handle = db.handle.clone();
437    let node = blocking("update memory", move || {
438        handle
439            .update_memory_fields(id, &patch)
440            .map_err(engine("update memory"))
441    })
442    .await?;
443    Ok(Json(memory_node_to_json(&node)))
444}
445
446// ---------------------------------------------------------------------------
447// POST /memories/{id}/supersede
448// ---------------------------------------------------------------------------
449
450/// Replace a memory. Body: `content` (required), `memory_type` (defaults to
451/// the old memory's), `attributes`, `tags`, `embedding`. The new memory keeps
452/// the old one's owners and space; the old one is closed now and linked from
453/// the new one by a Supersedes edge.
454pub async fn supersede_memory(
455    agent: Caller,
456    db: Db,
457    Path(params): Path<HashMap<String, String>>,
458    Json(req): Json<Value>,
459) -> Result<impl IntoResponse, ApiError> {
460    let old_id = memory_id_param(&params)?;
461    let old = load_owned(&agent, &db, old_id)?;
462    let content = req
463        .get("content")
464        .and_then(|v| v.as_str())
465        .filter(|s| !s.is_empty())
466        .ok_or_else(|| ApiError::BadRequest("missing 'content'".into()))?
467        .to_string();
468    let memory_type = if req.get("memory_type").is_some() {
469        parse_memory_type(&req)?
470    } else {
471        old.memory_type
472    };
473    let mut node = MemoryNode::new(
474        old.agent_id,
475        memory_type,
476        content,
477        parse_embedding(&req, false)?,
478    )
479    .with_user_id(old.user_id);
480    node.space_id = old.space_id;
481    node.attributes = parse_attributes(req.get("attributes"));
482    node.tags = string_array(&req, "tags")?;
483    let new_id = node.id;
484    let handle = db.handle.clone();
485    let at = now_micros();
486    let stored = blocking("supersede memory", move || {
487        handle
488            .supersede_memory(old_id, node, at)
489            .map_err(|e| match e {
490                MenteError::MemoryNotFound(id) => {
491                    ApiError::NotFound(format!("memory {id} not found"))
492                }
493                MenteError::Storage(msg) => ApiError::Conflict(msg),
494                other => ApiError::Internal(format!("supersede failed: {other}")),
495            })
496    })
497    .await?;
498    debug_assert_eq!(stored, new_id);
499    Ok((
500        StatusCode::CREATED,
501        Json(json!({
502            "id": stored.to_string(),
503            "supersedes": old_id.to_string(),
504            "superseded_at": at,
505        })),
506    ))
507}
508
509// ---------------------------------------------------------------------------
510// POST /recall_for_action
511// ---------------------------------------------------------------------------
512
513/// Memories that bear on an action about to happen. Body: `action` (text),
514/// optional `embedding` (else the action is embedded), `k`, `agent_id`,
515/// `user_id`.
516pub async fn recall_for_action(
517    agent: Caller,
518    db: Db,
519    Json(req): Json<Value>,
520) -> Result<Json<Value>, ApiError> {
521    let cfg = hosting_config();
522    let action = req
523        .get("action")
524        .and_then(|v| v.as_str())
525        .filter(|s| !s.is_empty())
526        .map(String::from);
527    let embedding = match (req.get("embedding"), &action) {
528        (Some(_), _) => parse_embedding(&req, true)?,
529        (None, Some(text)) => embed_query(&db, text).await?,
530        (None, None) => {
531            return Err(ApiError::BadRequest(
532                "send 'action' text or an 'embedding' vector".into(),
533            ));
534        }
535    };
536    let k = bounded(
537        body_usize(&req, "k")?,
538        cfg.action_default_k,
539        cfg.action_max_k,
540    );
541    let agent_scope = scope_agent(&agent, parse_optional_uuid(&req, "agent_id")?)?;
542    let user_scope = parse_optional_uuid(&req, "user_id")?.map(UserId);
543    let handle = db.handle.clone();
544    let nodes = blocking("recall for action", move || {
545        handle
546            .recall_for_action(&embedding, action.as_deref(), agent_scope, user_scope, k)
547            .map_err(engine("recall for action"))
548    })
549    .await?;
550    let memories: Vec<Value> = nodes.iter().map(memory_node_to_json).collect();
551    Ok(Json(json!({ "database": db.name, "memories": memories })))
552}
553
554// ---------------------------------------------------------------------------
555// GET /consolidation_candidates, POST /consolidate
556// ---------------------------------------------------------------------------
557
558/// Clusters of near duplicate memories that could be merged. Read only.
559/// Query: `min_cluster` (default from the server's consolidation settings),
560/// `similarity` (0 to 1). A token bound to an agent sees only clusters made
561/// entirely of memories it may see.
562pub async fn consolidation_candidates(
563    agent: Caller,
564    deadline: Option<Extension<Deadline>>,
565    db: Db,
566    Query(q): Query<HashMap<String, String>>,
567) -> Result<Json<Value>, ApiError> {
568    let ops = crate::handlers_ops::ops_config();
569    let min_cluster = query_usize(&q, "min_cluster")?
570        .unwrap_or(ops.consolidation_min_cluster_size)
571        .max(2);
572    let similarity = match q.get("similarity") {
573        None => ops.consolidation_similarity,
574        Some(s) => s
575            .trim()
576            .parse::<f32>()
577            .ok()
578            .filter(|v| (0.0..=1.0).contains(v))
579            .ok_or_else(|| ApiError::BadRequest("'similarity' must be between 0 and 1".into()))?,
580    };
581    let owner = token_agent(&agent)?;
582    check_deadline(&deadline)?;
583    let handle = db.handle.clone();
584    let clusters = blocking("consolidation candidates", move || {
585        let found = handle
586            .find_consolidation_candidates(min_cluster, similarity)
587            .map_err(engine("consolidation candidates"))?;
588        Ok(found
589            .into_iter()
590            .filter_map(|c| {
591                let nodes: Vec<MemoryNode> = c
592                    .memories
593                    .iter()
594                    .filter_map(|id| handle.get_memory(*id).ok())
595                    .collect();
596                (nodes.len() == c.memories.len() && nodes.iter().all(|n| visible(owner, n))).then(
597                    || {
598                        json!({
599                            "topic": c.topic,
600                            "avg_similarity": c.avg_similarity,
601                            "memories": nodes.iter().map(memory_node_to_json).collect::<Vec<_>>(),
602                        })
603                    },
604                )
605            })
606            .collect::<Vec<Value>>())
607    })
608    .await?;
609    check_deadline(&deadline)?;
610    Ok(Json(json!({
611        "database": db.name,
612        "min_cluster": min_cluster,
613        "similarity": similarity,
614        "clusters": clusters,
615    })))
616}
617
618/// Merge exactly the given memories. Body: `ids` (two or more). The sources
619/// are closed, not deleted, and a new semantic memory links back to them.
620pub async fn consolidate(
621    agent: Caller,
622    db: Db,
623    Json(req): Json<Value>,
624) -> Result<impl IntoResponse, ApiError> {
625    let ids: Vec<MemoryId> = string_array(&req, "ids")?
626        .iter()
627        .map(|s| {
628            s.parse()
629                .map_err(|_| ApiError::BadRequest(format!("invalid id in 'ids': {s}")))
630        })
631        .collect::<Result<_, _>>()?;
632    let mut unique = ids.clone();
633    unique.sort();
634    unique.dedup();
635    if unique.len() < 2 {
636        return Err(ApiError::BadRequest(
637            "'ids' must name at least two different memories".into(),
638        ));
639    }
640    for id in &unique {
641        load_owned(&agent, &db, *id)?;
642    }
643    let handle = db.handle.clone();
644    let merged = blocking("consolidate", move || {
645        handle
646            .consolidate_cluster(&unique)
647            .map_err(engine("consolidate"))
648    })
649    .await?;
650    Ok((
651        StatusCode::CREATED,
652        Json(json!({
653            "database": db.name,
654            "id": merged.to_string(),
655            "consolidated": ids.iter().map(|i| i.to_string()).collect::<Vec<_>>(),
656        })),
657    ))
658}
659
660// ---------------------------------------------------------------------------
661// DELETE /v1/db/{db}
662// ---------------------------------------------------------------------------
663
664/// Allow a database maintenance operation (drop, backup) on `name`: the
665/// admin key, an admin token, or a token with the maintenance scope whose
666/// `dbs` names the database. Without any of these the admin key check's
667/// answer stands (401 with no key, 403 with a wrong one).
668pub fn authorize_maintenance(
669    state: &AppState,
670    headers: &HeaderMap,
671    agent: Option<&AuthenticatedAgent>,
672    name: &str,
673) -> Result<(), ApiError> {
674    let key_check = crate::auth::admin_authorized(headers, &state.admin_key);
675    if key_check.is_ok() {
676        return Ok(());
677    }
678    match agent {
679        Some(a) => a.authorize_maintenance(name),
680        None => key_check,
681    }
682}
683
684/// Close and permanently delete a named database.
685pub async fn drop_database(
686    State(state): State<Arc<AppState>>,
687    agent: Caller,
688    headers: HeaderMap,
689    Path(name): Path<String>,
690) -> Result<Json<Value>, ApiError> {
691    if name == DEFAULT_DB {
692        return Err(ApiError::BadRequest(
693            "the default database cannot be deleted".into(),
694        ));
695    }
696    validate_name(&name)?;
697    authorize_maintenance(&state, &headers, agent.as_ref().map(|e| &e.0), &name)?;
698    let dbs = state.databases.clone();
699    let target = name.clone();
700    blocking("drop database", move || dbs.delete(&target)).await?;
701    Ok(Json(json!({ "name": name, "status": "deleted" })))
702}
703
704// ---------------------------------------------------------------------------
705// POST /v1/batch/search
706// ---------------------------------------------------------------------------
707
708/// Hybrid search across several databases in one call, the query embedded
709/// once. Body: `databases` (names), `query` and/or `embedding`, `k`,
710/// `context` (also return each database's heads up context), `max_items`
711/// (context size), `agent_id`, `user_id`. Every database must be readable by
712/// the token, or the whole request is refused. A database that does not exist
713/// is reported under `errors` without stopping the others.
714pub async fn batch_search(
715    State(state): State<Arc<AppState>>,
716    agent: Caller,
717    deadline: Option<Extension<Deadline>>,
718    Json(req): Json<Value>,
719) -> Result<Json<Value>, ApiError> {
720    let cfg = hosting_config();
721    let names = string_array(&req, "databases")?;
722    if names.is_empty() {
723        return Err(ApiError::BadRequest(
724            "'databases' must name at least one".into(),
725        ));
726    }
727    if names.len() > cfg.batch_max_databases {
728        return Err(ApiError::BadRequest(format!(
729            "at most {} databases per batch",
730            cfg.batch_max_databases
731        )));
732    }
733    for name in &names {
734        if name != DEFAULT_DB {
735            validate_name(name)?;
736        }
737        if let Some(Extension(a)) = &agent {
738            a.authorize_db(name, false)?;
739        }
740    }
741    let k = bounded(body_usize(&req, "k")?, cfg.batch_default_k, cfg.batch_max_k);
742    let with_context = req
743        .get("context")
744        .and_then(|v| v.as_bool())
745        .unwrap_or(false);
746    let max_items = bounded(body_usize(&req, "max_items")?, 10, 100);
747    let query_text = req
748        .get("query")
749        .and_then(|v| v.as_str())
750        .filter(|s| !s.is_empty())
751        .map(String::from);
752    let agent_scope = scope_agent(&agent, parse_optional_uuid(&req, "agent_id")?)?;
753    let user_scope = parse_optional_uuid(&req, "user_id")?.map(UserId);
754
755    // One embedding for every database: they share the server's embedder.
756    let embedding = match (req.get("embedding"), &query_text) {
757        (Some(_), _) => parse_embedding(&req, true)?,
758        (None, Some(text)) => {
759            let embedder = state.databases.embedder().ok_or_else(|| {
760                ApiError::BadRequest(
761                    "this server has no embedder; send an 'embedding' vector instead".into(),
762                )
763            })?;
764            let text = text.clone();
765            blocking("embed query", move || {
766                embedder
767                    .embed(&text)
768                    .map_err(|e| ApiError::ServiceUnavailable(format!("embedding failed: {e}")))
769            })
770            .await?
771        }
772        (None, None) => {
773            return Err(ApiError::BadRequest(
774                "send 'query' text or an 'embedding' vector".into(),
775            ));
776        }
777    };
778    let embedding = Arc::new(embedding);
779
780    let mut results = Vec::new();
781    let mut errors = serde_json::Map::new();
782    for name in names {
783        check_deadline(&deadline)?;
784        let dbs = state.databases.clone();
785        let emb = embedding.clone();
786        let text = query_text.clone();
787        let lookup = name.clone();
788        let outcome = blocking("batch search", move || {
789            if !dbs.exists(&lookup) {
790                return Ok(Err(format!("database '{lookup}' not found")));
791            }
792            let handle = dbs.get(&lookup, false)?;
793            let hits = handle
794                .recall_hybrid_scoped_with(
795                    &emb,
796                    text.as_deref(),
797                    k,
798                    now_micros(),
799                    None,
800                    false,
801                    None,
802                    agent_scope,
803                    user_scope,
804                    None,
805                    &HybridOptions {
806                        record_access: true,
807                        ..HybridOptions::default()
808                    },
809                )
810                .map_err(engine("hybrid search"))?;
811            let found: Vec<Value> = hits
812                .into_iter()
813                .filter_map(|(id, score)| {
814                    let node = handle.get_memory(id).ok()?;
815                    let mut j = memory_node_to_json(&node);
816                    if let Value::Object(m) = &mut j {
817                        m.insert("score".into(), json!(score));
818                    }
819                    Some(j)
820                })
821                .collect();
822            let context = if with_context {
823                let selected = handle
824                    .recall_for_injection(&InjectionQuery {
825                        embedding: &emb,
826                        query_text: text.as_deref(),
827                        session_id: None,
828                        exclude_ids: &[],
829                        max_items,
830                        max_episodic: max_items.min(3),
831                        agent_id: agent_scope,
832                        user_id: user_scope,
833                        current_project: None,
834                    })
835                    .map_err(engine("context recall"))?;
836                Some(
837                    selected
838                        .iter()
839                        .map(|c| {
840                            let mut j = memory_node_to_json(&c.node);
841                            if let Value::Object(m) = &mut j {
842                                m.insert("score".into(), json!(c.score));
843                            }
844                            j
845                        })
846                        .collect::<Vec<Value>>(),
847                )
848            } else {
849                None
850            };
851            Ok(Ok((found, context)))
852        })
853        .await?;
854        match outcome {
855            Ok((found, context)) => {
856                let mut entry = json!({ "database": name, "results": found });
857                if let (Some(ctx), Value::Object(m)) = (context, &mut entry) {
858                    m.insert("context".into(), json!(ctx));
859                }
860                results.push(entry);
861            }
862            Err(msg) => {
863                errors.insert(name, json!(msg));
864            }
865        }
866    }
867    Ok(Json(json!({ "results": results, "errors": errors })))
868}
869
870// ---------------------------------------------------------------------------
871// GET /v1/live
872// ---------------------------------------------------------------------------
873
874/// Liveness: the process answers. Touches no database, disk or embedder, so a
875/// slow dependency never gets a healthy process restarted. Point liveness
876/// probes here and readiness probes at `/v1/ready`.
877pub async fn live() -> Json<Value> {
878    Json(json!({ "status": "alive" }))
879}
880
881// ---------------------------------------------------------------------------
882// Enrichment pool
883// ---------------------------------------------------------------------------
884
885/// Enrichment runs on its own small set of threads with bounded concurrency,
886/// so a long LLM pipeline never occupies the threads that serve reads.
887struct EnrichmentPool {
888    runtime: tokio::runtime::Runtime,
889    permits: Arc<tokio::sync::Semaphore>,
890}
891
892static ENRICHMENT_POOL: OnceLock<Result<EnrichmentPool, String>> = OnceLock::new();
893
894fn enrichment_pool() -> Result<&'static EnrichmentPool, ApiError> {
895    ENRICHMENT_POOL
896        .get_or_init(|| {
897            let workers = hosting_config().enrich_workers.max(1);
898            tokio::runtime::Builder::new_multi_thread()
899                .worker_threads(workers)
900                .thread_name("mentedb-enrich")
901                .enable_all()
902                .build()
903                .map(|runtime| EnrichmentPool {
904                    runtime,
905                    permits: Arc::new(tokio::sync::Semaphore::new(workers)),
906                })
907                .map_err(|e| e.to_string())
908        })
909        .as_ref()
910        .map_err(|e| ApiError::Internal(format!("enrichment pool unavailable: {e}")))
911}
912
913/// Run `job` on the enrichment pool, waiting for a free slot first, and
914/// return its output.
915pub async fn run_on_enrichment_pool<F, T>(job: F) -> Result<T, ApiError>
916where
917    F: Future<Output = T> + Send + 'static,
918    T: Send + 'static,
919{
920    let pool = enrichment_pool()?;
921    let permits = pool.permits.clone();
922    pool.runtime
923        .spawn(async move {
924            // The semaphore is never closed, so acquire cannot fail; a closed
925            // one would just run the job unbounded.
926            let _slot = permits.acquire_owned().await.ok();
927            job.await
928        })
929        .await
930        .map_err(|e| ApiError::Internal(format!("enrichment task failed: {e}")))
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936    use std::sync::atomic::{AtomicUsize, Ordering};
937
938    #[test]
939    fn query_bool_accepts_zero_and_one() {
940        let q = |v: &str| HashMap::from([("x".to_string(), v.to_string())]);
941        assert!(query_bool(&q("1"), "x").unwrap());
942        assert!(!query_bool(&q("0"), "x").unwrap());
943        assert!(!query_bool(&HashMap::new(), "x").unwrap());
944        assert!(query_bool(&q("yes please"), "x").is_err());
945    }
946
947    #[tokio::test]
948    async fn enrichment_pool_bounds_concurrency_and_runs_off_the_caller() {
949        let running = Arc::new(AtomicUsize::new(0));
950        let peak = Arc::new(AtomicUsize::new(0));
951        let mut jobs = Vec::new();
952        for _ in 0..4 {
953            let (running, peak) = (running.clone(), peak.clone());
954            jobs.push(tokio::spawn(run_on_enrichment_pool(async move {
955                let now = running.fetch_add(1, Ordering::SeqCst) + 1;
956                peak.fetch_max(now, Ordering::SeqCst);
957                tokio::time::sleep(Duration::from_millis(30)).await;
958                running.fetch_sub(1, Ordering::SeqCst);
959                std::thread::current().name().map(String::from)
960            })));
961        }
962        for j in jobs {
963            let name = j.await.unwrap().unwrap();
964            assert_eq!(name.as_deref(), Some("mentedb-enrich"));
965        }
966        assert_eq!(
967            peak.load(Ordering::SeqCst),
968            hosting_config().enrich_workers.max(1)
969        );
970    }
971}