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