1pub mod raw;
4pub mod relations;
5pub mod streaming;
6
7pub use raw::upsert_points_raw;
8pub use relations::{get_point_relations, relate_points, set_point_ttl, unrelate_points};
9pub use streaming::{
10 __path_enable_streaming, __path_stream_insert, __path_stream_upsert_points, enable_streaming,
11 stream_insert, stream_upsert_points,
12};
13
14use axum::{
15 extract::{Path, State},
16 http::StatusCode,
17 response::IntoResponse,
18 Json,
19};
20use std::sync::Arc;
21
22use crate::types::{
23 ErrorResponse, ScrollPoint, ScrollRequest, ScrollResponse, SparseVectorInput,
24 UpsertPointsRequest,
25};
26use crate::AppState;
27use velesdb_core::api_types::serde_id;
28use velesdb_core::Point;
29
30use crate::handlers::helpers::{
31 auto_core_error_response, error_response, get_vector_collection_or_404, run_blocking,
32};
33
34use velesdb_core::index::sparse::SparseVector;
35
36fn convert_sparse_inputs(
41 sparse_vector: Option<SparseVectorInput>,
42 sparse_vectors: Option<std::collections::BTreeMap<String, SparseVectorInput>>,
43) -> Result<Option<std::collections::BTreeMap<String, SparseVector>>, String> {
44 let has_single = sparse_vector.is_some();
45 let has_named = sparse_vectors.as_ref().is_some_and(|m| !m.is_empty());
46
47 if !has_single && !has_named {
48 return Ok(None);
49 }
50
51 let mut result = std::collections::BTreeMap::new();
52
53 if let Some(sv_input) = sparse_vector {
55 let sv = sv_input.into_sparse_vector()?;
56 result.insert(String::new(), sv);
57 }
58
59 if let Some(named) = sparse_vectors {
61 merge_named_sparse_vectors(named, &mut result)?;
62 }
63
64 Ok(Some(result))
65}
66
67fn merge_named_sparse_vectors(
73 named: std::collections::BTreeMap<String, SparseVectorInput>,
74 result: &mut std::collections::BTreeMap<String, SparseVector>,
75) -> Result<(), String> {
76 for (name, sv_input) in named {
77 let sv = sv_input
78 .into_sparse_vector()
79 .map_err(|e| format!("sparse_vectors['{name}']: {e}"))?;
80 if name.is_empty() && result.contains_key("") {
81 tracing::debug!(
82 "sparse_vector (default \"\") is being overwritten by \
83 sparse_vectors[\"\"] — supply only one to avoid ambiguity"
84 );
85 }
86 result.insert(name, sv);
87 }
88 Ok(())
89}
90
91const MAX_UPSERT_BATCH_SIZE: usize = 100_000;
98
99#[utoipa::path(
101 post,
102 path = "/collections/{name}/points",
103 tag = "points",
104 params(
105 ("name" = String, Path, description = "Collection name")
106 ),
107 request_body = UpsertPointsRequest,
108 responses(
109 (status = 200, description = "Points upserted", body = Object),
110 (status = 404, description = "Collection not found", body = ErrorResponse),
111 (status = 400, description = "Invalid request or batch too large", body = ErrorResponse)
112 )
113)]
114pub async fn upsert_points(
115 State(state): State<Arc<AppState>>,
116 Path(name): Path<String>,
117 Json(req): Json<UpsertPointsRequest>,
118) -> impl IntoResponse {
119 if req.points.len() > MAX_UPSERT_BATCH_SIZE {
120 return error_response(
121 StatusCode::BAD_REQUEST,
122 format!(
123 "Batch too large: {} points (max {MAX_UPSERT_BATCH_SIZE})",
124 req.points.len()
125 ),
126 );
127 }
128
129 let collection = match get_vector_collection_or_404(&state, &name) {
130 Ok(c) => c,
131 Err(resp) => return resp,
132 };
133
134 let points = match build_points_from_request(req) {
135 Ok(p) => p,
136 Err(e) => {
137 return error_response(StatusCode::BAD_REQUEST, e);
138 }
139 };
140
141 let result = tokio::task::spawn_blocking(move || collection.upsert_bulk(&points)).await;
144
145 upsert_result_to_response(&state, &name, result)
146}
147
148pub(super) fn upsert_result_to_response(
154 state: &AppState,
155 name: &str,
156 result: Result<velesdb_core::Result<usize>, tokio::task::JoinError>,
157) -> axum::response::Response {
158 match result {
159 Ok(Ok(inserted)) => {
160 #[allow(deprecated)]
164 state.db.notify_upsert(name, inserted);
165 Json(serde_json::json!({
166 "message": "Points upserted",
167 "count": inserted
168 }))
169 .into_response()
170 }
171 Ok(Err(e)) => auto_core_error_response(&e),
172 Err(e) => error_response(
173 StatusCode::INTERNAL_SERVER_ERROR,
174 format!("Task panicked: {e}"),
175 ),
176 }
177}
178
179fn build_points_from_request(req: UpsertPointsRequest) -> Result<Vec<Point>, String> {
181 let mut points: Vec<Point> = Vec::with_capacity(req.points.len());
182 for p in req.points {
183 let sparse = convert_sparse_inputs(p.sparse_vector, p.sparse_vectors)?;
184 let mut point = Point::new(p.id, p.vector, p.payload);
185 point.sparse_vectors = sparse;
186 points.push(point);
187 }
188 Ok(points)
189}
190
191#[utoipa::path(
193 get,
194 path = "/collections/{name}/points/{id}",
195 tag = "points",
196 params(
197 ("name" = String, Path, description = "Collection name"),
198 ("id" = String, Path, description = "Point ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
199 ),
200 responses(
201 (status = 200, description = "Point found", body = Object),
202 (status = 404, description = "Point or collection not found", body = ErrorResponse)
203 )
204)]
205pub async fn get_point(
206 State(state): State<Arc<AppState>>,
207 Path((name, id)): Path<(String, u64)>,
208) -> impl IntoResponse {
209 let collection = match get_vector_collection_or_404(&state, &name) {
210 Ok(c) => c,
211 Err(resp) => return resp,
212 };
213
214 let points = match run_blocking(move || collection.get(&[id])).await {
216 Ok(p) => p,
217 Err(resp) => return resp,
218 };
219
220 match points.into_iter().next().flatten() {
221 Some(point) => Json(serde_json::json!({
224 "id": point.id.to_string(),
225 "vector": point.vector,
226 "payload": point.payload
227 }))
228 .into_response(),
229 None => auto_core_error_response(&velesdb_core::Error::PointNotFound(id)),
233 }
234}
235
236#[utoipa::path(
238 delete,
239 path = "/collections/{name}/points/{id}",
240 tag = "points",
241 params(
242 ("name" = String, Path, description = "Collection name"),
243 ("id" = String, Path, description = "Point ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
244 ),
245 responses(
246 (status = 200, description = "Point deleted", body = Object),
247 (status = 404, description = "Point or collection not found", body = ErrorResponse)
248 )
249)]
250pub async fn delete_point(
251 State(state): State<Arc<AppState>>,
252 Path((name, id)): Path<(String, u64)>,
253) -> impl IntoResponse {
254 let collection = match get_vector_collection_or_404(&state, &name) {
255 Ok(c) => c,
256 Err(resp) => return resp,
257 };
258
259 match run_blocking(move || collection.delete(&[id])).await {
261 Ok(Ok(())) => Json(serde_json::json!({
264 "message": "Point deleted",
265 "id": id.to_string()
266 }))
267 .into_response(),
268 Ok(Err(e)) => auto_core_error_response(&e),
269 Err(resp) => resp,
270 }
271}
272
273const MAX_SCROLL_BATCH_SIZE: u32 = 10_000;
275
276#[utoipa::path(
278 post,
279 path = "/collections/{name}/points/scroll",
280 tag = "points",
281 params(("name" = String, Path, description = "Collection name")),
282 request_body = ScrollRequest,
283 responses(
284 (status = 200, description = "Scroll batch", body = ScrollResponse),
285 (status = 400, description = "Invalid request", body = ErrorResponse),
286 (status = 404, description = "Collection not found", body = ErrorResponse)
287 )
288)]
289pub async fn scroll_points(
290 State(state): State<Arc<AppState>>,
291 Path(name): Path<String>,
292 Json(req): Json<ScrollRequest>,
293) -> impl IntoResponse {
294 if req.batch_size == 0 || req.batch_size > MAX_SCROLL_BATCH_SIZE {
295 return error_response(
296 StatusCode::BAD_REQUEST,
297 "batch_size must be between 1 and 10000".to_string(),
298 );
299 }
300
301 let collection = match get_vector_collection_or_404(&state, &name) {
302 Ok(c) => c,
303 Err(resp) => return resp,
304 };
305
306 let filter = match parse_scroll_filter(&req.filter) {
307 Ok(f) => f,
308 Err(resp) => return resp,
309 };
310
311 let batch_size = req.batch_size as usize;
312 let cursor = req.cursor;
313
314 let result = tokio::task::spawn_blocking(move || {
316 collection.scroll_batch(cursor, batch_size, filter.as_ref())
317 })
318 .await;
319
320 match result {
321 Ok(Ok(batch)) => build_scroll_response(batch),
322 Ok(Err(e)) => auto_core_error_response(&e),
323 Err(e) => error_response(
324 StatusCode::INTERNAL_SERVER_ERROR,
325 format!("Task panicked: {e}"),
326 ),
327 }
328}
329
330#[allow(clippy::result_large_err)]
332fn parse_scroll_filter(
333 filter_json: &Option<serde_json::Value>,
334) -> Result<Option<velesdb_core::Filter>, axum::response::Response> {
335 let Some(ref json) = filter_json else {
336 return Ok(None);
337 };
338 serde_json::from_value::<velesdb_core::Filter>(json.clone())
339 .map(Some)
340 .map_err(|e| error_response(StatusCode::BAD_REQUEST, format!("Invalid filter: {e}")))
341}
342
343fn build_scroll_response(batch: velesdb_core::ScrollBatch) -> axum::response::Response {
345 let points: Vec<ScrollPoint> = batch
346 .points
347 .into_iter()
348 .map(|p| ScrollPoint {
349 id: p.id,
350 vector: p.vector,
351 payload: p.payload,
352 })
353 .collect();
354 Json(ScrollResponse {
355 next_cursor: batch.next_cursor,
356 points,
357 })
358 .into_response()
359}
360
361const MAX_BULK_DELETE_SIZE: usize = 10_000;
363
364#[derive(serde::Deserialize, utoipa::ToSchema)]
366pub struct BulkDeleteRequest {
367 #[serde(deserialize_with = "serde_id::deserialize_ids_from_string_or_number")]
369 #[cfg_attr(feature = "openapi", schema(schema_with = serde_id::ids_array_schema))]
370 pub ids: Vec<u64>,
371}
372
373#[utoipa::path(
394 post,
395 path = "/collections/{name}/points/delete",
396 tag = "points",
397 params(
398 ("name" = String, Path, description = "Collection name")
399 ),
400 request_body = BulkDeleteRequest,
401 responses(
402 (status = 200, description = "Points deleted", body = Object),
403 (status = 400, description = "Batch too large", body = ErrorResponse),
404 (status = 404, description = "Collection not found", body = ErrorResponse),
405 (status = 500, description = "Delete failed", body = ErrorResponse)
406 )
407)]
408pub async fn bulk_delete_points(
409 State(state): State<Arc<AppState>>,
410 Path(name): Path<String>,
411 Json(req): Json<BulkDeleteRequest>,
412) -> impl IntoResponse {
413 if req.ids.is_empty() {
414 return Json(serde_json::json!({
415 "message": "No points to delete",
416 "collection": name,
417 "deleted_count": 0
418 }))
419 .into_response();
420 }
421
422 if req.ids.len() > MAX_BULK_DELETE_SIZE {
423 return error_response(
424 StatusCode::BAD_REQUEST,
425 format!(
426 "Batch too large: {} IDs (max {MAX_BULK_DELETE_SIZE})",
427 req.ids.len()
428 ),
429 );
430 }
431
432 let collection = match get_vector_collection_or_404(&state, &name) {
433 Ok(c) => c,
434 Err(resp) => return resp,
435 };
436
437 let ids = req.ids;
438 let count = ids.len();
439 let coll_name = name.clone();
440
441 let result = tokio::task::spawn_blocking(move || collection.delete(&ids)).await;
442 match result {
443 Ok(Ok(())) => Json(serde_json::json!({
444 "message": "Points deleted",
445 "collection": coll_name,
446 "deleted_count": count
447 }))
448 .into_response(),
449 Ok(Err(e)) => auto_core_error_response(&e),
450 Err(join_err) => error_response(
451 StatusCode::INTERNAL_SERVER_ERROR,
452 format!("bulk_delete task panicked: {join_err}"),
453 ),
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
462 fn upsert_batch_constant_matches_expected_value() {
463 assert_eq!(MAX_UPSERT_BATCH_SIZE, 100_000);
464 }
465
466 #[test]
467 fn scroll_batch_constant_matches_expected_value() {
468 assert_eq!(MAX_SCROLL_BATCH_SIZE, 10_000);
469 }
470
471 #[test]
472 fn bulk_delete_batch_constant_matches_expected_value() {
473 assert_eq!(MAX_BULK_DELETE_SIZE, 10_000);
474 }
475
476 #[test]
477 fn upsert_batch_limit_is_larger_than_delete_limit() {
478 assert!(MAX_UPSERT_BATCH_SIZE > MAX_BULK_DELETE_SIZE);
480 }
481}