1use axum::{
4 extract::{Path, State},
5 http::StatusCode,
6 response::IntoResponse,
7 Json,
8};
9use std::sync::Arc;
10
11use crate::types::{CollectionResponse, CreateCollectionRequest, ErrorResponse};
12use crate::AppState;
13use velesdb_core::index::HnswParams;
14use velesdb_core::{DistanceMetric, StorageMode};
15
16use super::helpers::{
17 auto_core_error_response, error_response, get_collection_or_404, run_blocking,
18};
19
20#[utoipa::path(
22 get,
23 path = "/collections",
24 tag = "collections",
25 responses(
26 (status = 200, description = "List of collections", body = Object)
27 )
28)]
29pub async fn list_collections(State(state): State<Arc<AppState>>) -> impl IntoResponse {
30 let collections = state.db.list_collections();
31 Json(serde_json::json!({ "collections": collections }))
32}
33
34#[utoipa::path(
36 post,
37 path = "/collections",
38 tag = "collections",
39 request_body = CreateCollectionRequest,
40 responses(
41 (status = 201, description = "Collection created", body = Object),
42 (status = 400, description = "Invalid request", body = ErrorResponse)
43 )
44)]
45pub async fn create_collection(
46 State(state): State<Arc<AppState>>,
47 Json(req): Json<CreateCollectionRequest>,
48) -> impl IntoResponse {
49 let metric = match parse_distance_metric(&req.metric) {
50 Ok(m) => m,
51 Err(resp) => return resp,
52 };
53
54 let storage_mode = match parse_storage_mode(&req.storage_mode) {
55 Ok(s) => s,
56 Err(resp) => return resp,
57 };
58
59 let state_clone = Arc::clone(&state);
62 run_blocking(
63 move || match dispatch_create(&state_clone, &req, metric, storage_mode) {
64 Ok(Ok(())) => create_collection_success_response(&req),
65 Ok(Err(e)) => auto_core_error_response(&e),
66 Err(resp) => resp,
67 },
68 )
69 .await
70 .unwrap_or_else(|resp| resp)
71}
72
73#[allow(clippy::result_large_err)]
77fn parse_distance_metric(raw: &str) -> Result<DistanceMetric, axum::response::Response> {
78 raw.parse::<DistanceMetric>()
79 .map_err(|e| error_response(StatusCode::BAD_REQUEST, e.to_string()))
80}
81
82#[allow(clippy::result_large_err)]
86fn parse_storage_mode(raw: &str) -> Result<StorageMode, axum::response::Response> {
87 raw.parse::<StorageMode>()
88 .map_err(|e| error_response(StatusCode::BAD_REQUEST, e))
89}
90
91fn build_hnsw_params_override(
101 req: &CreateCollectionRequest,
102 dimension: usize,
103 storage_mode: StorageMode,
104) -> Option<HnswParams> {
105 if req.hnsw_m.is_none()
106 && req.hnsw_ef_construction.is_none()
107 && req.hnsw_alpha.is_none()
108 && req.hnsw_max_elements.is_none()
109 {
110 return None;
111 }
112 let base = HnswParams::auto(dimension);
113 Some(HnswParams {
114 max_connections: req.hnsw_m.unwrap_or(base.max_connections),
115 ef_construction: req.hnsw_ef_construction.unwrap_or(base.ef_construction),
116 max_elements: req.hnsw_max_elements.unwrap_or(base.max_elements),
117 storage_mode,
118 alpha: req.hnsw_alpha.unwrap_or(base.alpha),
119 })
120}
121
122#[allow(clippy::result_large_err)]
132fn create_vector_collection(
133 state: &AppState,
134 req: &CreateCollectionRequest,
135 metric: DistanceMetric,
136 storage_mode: StorageMode,
137) -> Result<velesdb_core::error::Result<()>, axum::response::Response> {
138 let dimension = req.dimension.ok_or_else(|| {
139 error_response(
140 StatusCode::BAD_REQUEST,
141 "dimension is required for vector collections".to_string(),
142 )
143 })?;
144
145 let advanced = parse_advanced_config(req)?;
150
151 let base_result = if let Some(hnsw_params) =
160 build_hnsw_params_override(req, dimension, storage_mode)
161 {
162 hnsw_params
165 .validate()
166 .map_err(|e| error_response(StatusCode::BAD_REQUEST, e.to_string()))?;
167 state.db.create_vector_collection_with_params(
168 &req.name,
169 dimension,
170 metric,
171 storage_mode,
172 hnsw_params,
173 None,
174 )
175 } else {
176 state
177 .db
178 .create_vector_collection_with_options(&req.name, dimension, metric, storage_mode)
179 };
180 if let Err(e) = base_result {
181 return Ok(Err(e));
182 }
183
184 if advanced.has_any() {
186 return Ok(apply_advanced_with_rollback(state, &req.name, advanced));
187 }
188
189 Ok(Ok(()))
190}
191
192fn apply_advanced_with_rollback(
197 state: &AppState,
198 name: &str,
199 advanced: AdvancedCreateOverrides,
200) -> velesdb_core::error::Result<()> {
201 let Some(coll) = state.db.get_vector_collection(name) else {
202 return Err(velesdb_core::error::Error::CollectionNotFound(
203 name.to_string(),
204 ));
205 };
206 if let Err(phase_two_err) = coll.apply_advanced_config(
207 advanced.pq_rescore_oversampling,
208 advanced.deferred_indexing,
209 advanced.async_index_builder,
210 ) {
211 drop(coll);
212 let rollback_outcome = state.db.delete_collection(name);
213 if let Err(ref rollback_err) = rollback_outcome {
214 tracing::warn!(
215 collection = %name,
216 rollback_error = %rollback_err,
217 phase_two_error = %phase_two_err,
218 "failed to roll back collection after apply_advanced_config error"
219 );
220 }
221 log_rollback_invariant(state, name, &rollback_outcome, &phase_two_err);
222 return Err(phase_two_err);
223 }
224 Ok(())
225}
226
227fn log_rollback_invariant(
229 state: &AppState,
230 name: &str,
231 rollback_outcome: &velesdb_core::error::Result<()>,
232 phase_two_err: &velesdb_core::error::Error,
233) {
234 if state.db.get_any_collection(name).is_some() {
235 tracing::error!(
236 collection = %name,
237 rollback_outcome = ?rollback_outcome,
238 phase_two_error = %phase_two_err,
239 "post-rollback invariant violated: collection still present in \
240 registry after delete_collection was attempted. Manual \
241 reconciliation required — client retries will fail with \
242 CollectionExists until the orphaned collection is cleaned up."
243 );
244 }
245}
246
247#[allow(clippy::option_option)]
255#[derive(Default)]
256struct AdvancedCreateOverrides {
257 pq_rescore_oversampling: Option<Option<u32>>,
258 deferred_indexing: Option<Option<velesdb_core::collection::streaming::DeferredIndexerConfig>>,
259 async_index_builder:
260 Option<Option<velesdb_core::collection::streaming::AsyncIndexBuilderConfig>>,
261}
262
263impl AdvancedCreateOverrides {
264 fn has_any(&self) -> bool {
265 self.pq_rescore_oversampling.is_some()
266 || self.deferred_indexing.is_some()
267 || self.async_index_builder.is_some()
268 }
269}
270
271#[allow(clippy::result_large_err)]
275fn parse_advanced_config(
276 req: &CreateCollectionRequest,
277) -> Result<AdvancedCreateOverrides, axum::response::Response> {
278 let mut overrides = AdvancedCreateOverrides {
279 pq_rescore_oversampling: req.pq_rescore_oversampling.map(Some),
280 ..Default::default()
281 };
282
283 if let Some(ref value) = req.deferred_indexing {
284 let parsed: velesdb_core::collection::streaming::DeferredIndexerConfig =
285 serde_json::from_value(value.clone()).map_err(|e| {
286 error_response(
287 StatusCode::BAD_REQUEST,
288 format!("Invalid 'deferred_indexing' configuration: {e}"),
289 )
290 })?;
291 overrides.deferred_indexing = Some(Some(parsed));
292 }
293
294 if let Some(ref value) = req.async_index_builder {
295 let parsed: velesdb_core::collection::streaming::AsyncIndexBuilderConfig =
296 serde_json::from_value(value.clone()).map_err(|e| {
297 error_response(
298 StatusCode::BAD_REQUEST,
299 format!("Invalid 'async_index_builder' configuration: {e}"),
300 )
301 })?;
302 overrides.async_index_builder = Some(Some(parsed));
303 }
304
305 Ok(overrides)
306}
307
308#[allow(clippy::result_large_err)]
313fn parse_graph_schema(
314 req: &CreateCollectionRequest,
315) -> Result<velesdb_core::GraphSchema, axum::response::Response> {
316 match req.graph_schema.as_ref() {
317 Some(value) => serde_json::from_value(value.clone()).map_err(|e| {
318 error_response(
319 StatusCode::BAD_REQUEST,
320 format!("Invalid 'graph_schema' payload: {e}"),
321 )
322 }),
323 None => Ok(velesdb_core::GraphSchema::schemaless()),
324 }
325}
326
327#[allow(clippy::result_large_err)]
329fn dispatch_create(
330 state: &AppState,
331 req: &CreateCollectionRequest,
332 metric: DistanceMetric,
333 storage_mode: StorageMode,
334) -> Result<velesdb_core::error::Result<()>, axum::response::Response> {
335 match req.collection_type.to_lowercase().as_str() {
336 "metadata_only" | "metadata-only" | "metadata" => {
337 Ok(state.db.create_metadata_collection(&req.name))
338 }
339 "graph" | "knowledge_graph" | "kg" => {
340 let schema = parse_graph_schema(req)?;
341 Ok(state.db.create_graph_collection(&req.name, schema))
342 }
343 "vector" | "" => create_vector_collection(state, req, metric, storage_mode),
344 _ => Err(error_response(
345 StatusCode::BAD_REQUEST,
346 format!(
347 "Invalid collection_type: {}. Valid: vector, graph, metadata_only",
348 req.collection_type
349 ),
350 )),
351 }
352}
353
354fn create_collection_success_response(req: &CreateCollectionRequest) -> axum::response::Response {
356 let mut warnings = Vec::new();
357 let is_vector = matches!(req.collection_type.to_lowercase().as_str(), "vector" | "");
358 if is_vector {
359 warnings.push("Collection dimension and metric are immutable after creation. If your embedding model changes, create a new collection and reindex data.");
360 warnings.push("For first queries, start without strict filters/thresholds, then tighten progressively.");
361 }
362
363 (
364 StatusCode::CREATED,
365 Json(serde_json::json!({
366 "message": "Collection created",
367 "name": req.name,
368 "type": req.collection_type,
369 "warnings": warnings
370 })),
371 )
372 .into_response()
373}
374
375#[utoipa::path(
377 get,
378 path = "/collections/{name}",
379 tag = "collections",
380 params(
381 ("name" = String, Path, description = "Collection name")
382 ),
383 responses(
384 (status = 200, description = "Collection details", body = CollectionResponse),
385 (status = 404, description = "Collection not found", body = ErrorResponse)
386 )
387)]
388pub async fn get_collection(
389 State(state): State<Arc<AppState>>,
390 Path(name): Path<String>,
391) -> impl IntoResponse {
392 let collection = match get_collection_or_404(&state, &name) {
393 Ok(c) => c,
394 Err(resp) => return resp,
395 };
396
397 let config = collection.config();
398 Json(CollectionResponse {
399 name: config.name,
400 dimension: config.dimension,
401 metric: format!("{:?}", config.metric).to_lowercase(),
402 point_count: config.point_count,
403 storage_mode: format!("{:?}", config.storage_mode).to_lowercase(),
404 })
405 .into_response()
406}
407
408#[utoipa::path(
410 get,
411 path = "/collections/{name}/sanity",
412 tag = "collections",
413 params(
414 ("name" = String, Path, description = "Collection name")
415 ),
416 responses(
417 (status = 200, description = "Collection sanity status", body = Object),
418 (status = 404, description = "Collection not found", body = ErrorResponse)
419 )
420)]
421pub async fn collection_sanity(
422 State(state): State<Arc<AppState>>,
423 Path(name): Path<String>,
424) -> impl IntoResponse {
425 let collection = match get_collection_or_404(&state, &name) {
426 Ok(c) => c,
427 Err(resp) => return resp,
428 };
429
430 let config = collection.config();
431 build_sanity_response(&state, &config, &collection)
432}
433
434fn build_sanity_response(
436 state: &AppState,
437 config: &velesdb_core::collection::CollectionConfig,
438 collection: &velesdb_core::AnyCollection,
439) -> axum::response::Response {
440 let has_data = config.point_count > 0;
441 Json(serde_json::json!({
442 "collection": config.name,
443 "dimension": config.dimension,
444 "metric": format!("{:?}", config.metric).to_lowercase(),
445 "point_count": config.point_count,
446 "is_empty": collection.is_empty(),
447 "checks": {
448 "has_vectors": has_data,
449 "search_ready": has_data,
450 "dimension_configured": config.dimension > 0
451 },
452 "diagnostics": {
453 "search_requests_total": state.onboarding_metrics.search_requests_total.load(std::sync::atomic::Ordering::Relaxed),
454 "dimension_mismatch_total": state.onboarding_metrics.dimension_mismatch_total.load(std::sync::atomic::Ordering::Relaxed),
455 "empty_search_results_total": state.onboarding_metrics.empty_search_results_total.load(std::sync::atomic::Ordering::Relaxed),
456 "filter_parse_errors_total": state.onboarding_metrics.filter_parse_errors_total.load(std::sync::atomic::Ordering::Relaxed)
457 },
458 "hints": if has_data {
459 vec![
460 "Run a search without strict filters first, then tighten filters progressively."
461 ]
462 } else {
463 vec![
464 "Insert at least one known vector before evaluating search quality.",
465 "Verify you are querying the intended collection."
466 ]
467 }
468 }))
469 .into_response()
470}
471
472#[utoipa::path(
474 delete,
475 path = "/collections/{name}",
476 tag = "collections",
477 params(
478 ("name" = String, Path, description = "Collection name")
479 ),
480 responses(
481 (status = 200, description = "Collection deleted", body = Object),
482 (status = 404, description = "Collection not found", body = ErrorResponse)
483 )
484)]
485pub async fn delete_collection(
486 State(state): State<Arc<AppState>>,
487 Path(name): Path<String>,
488) -> impl IntoResponse {
489 let state_clone = Arc::clone(&state);
492 let coll_name = name.clone();
493 match run_blocking(move || state_clone.db.delete_collection(&coll_name)).await {
494 Ok(Ok(())) => Json(serde_json::json!({
495 "message": "Collection deleted",
496 "name": name
497 }))
498 .into_response(),
499 Ok(Err(e)) => auto_core_error_response(&e),
500 Err(resp) => resp,
501 }
502}
503
504#[utoipa::path(
506 get,
507 path = "/collections/{name}/empty",
508 tag = "collections",
509 params(
510 ("name" = String, Path, description = "Collection name")
511 ),
512 responses(
513 (status = 200, description = "Empty status", body = Object),
514 (status = 404, description = "Collection not found", body = ErrorResponse)
515 )
516)]
517pub async fn is_empty(
518 State(state): State<Arc<AppState>>,
519 Path(name): Path<String>,
520) -> impl IntoResponse {
521 let collection = match get_collection_or_404(&state, &name) {
522 Ok(c) => c,
523 Err(resp) => return resp,
524 };
525
526 Json(serde_json::json!({
527 "is_empty": collection.is_empty()
528 }))
529 .into_response()
530}
531
532#[utoipa::path(
534 post,
535 path = "/collections/{name}/flush",
536 tag = "collections",
537 params(
538 ("name" = String, Path, description = "Collection name")
539 ),
540 responses(
541 (status = 200, description = "Flushed successfully", body = Object),
542 (status = 404, description = "Collection not found", body = ErrorResponse),
543 (status = 500, description = "Flush failed", body = ErrorResponse)
544 )
545)]
546pub async fn flush_collection(
547 State(state): State<Arc<AppState>>,
548 Path(name): Path<String>,
549) -> impl IntoResponse {
550 let collection = match get_collection_or_404(&state, &name) {
551 Ok(c) => c,
552 Err(resp) => return resp,
553 };
554
555 let result = tokio::task::spawn_blocking(move || collection.flush()).await;
556 match result {
557 Ok(Ok(())) => Json(serde_json::json!({
558 "message": "Flushed successfully",
559 "collection": name
560 }))
561 .into_response(),
562 Ok(Err(e)) => auto_core_error_response(&e),
563 Err(join_err) => error_response(
564 StatusCode::INTERNAL_SERVER_ERROR,
565 format!("flush task panicked: {join_err}"),
566 ),
567 }
568}