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 (status = 422, response = crate::types::MalformedBody)
113 )
114)]
115pub async fn upsert_points(
116 State(state): State<Arc<AppState>>,
117 Path(name): Path<String>,
118 Json(req): Json<UpsertPointsRequest>,
119) -> impl IntoResponse {
120 if req.points.len() > MAX_UPSERT_BATCH_SIZE {
121 return error_response(
122 StatusCode::BAD_REQUEST,
123 format!(
124 "Batch too large: {} points (max {MAX_UPSERT_BATCH_SIZE})",
125 req.points.len()
126 ),
127 );
128 }
129
130 let collection = match get_vector_collection_or_404(&state, &name) {
131 Ok(c) => c,
132 Err(resp) => return resp,
133 };
134
135 let points = match build_points_from_request(req) {
136 Ok(p) => p,
137 Err(e) => {
138 return error_response(StatusCode::BAD_REQUEST, e);
139 }
140 };
141
142 let result = tokio::task::spawn_blocking(move || collection.upsert_bulk(&points)).await;
145
146 upsert_result_to_response(&state, &name, result)
147}
148
149pub(super) fn upsert_result_to_response(
155 state: &AppState,
156 name: &str,
157 result: Result<velesdb_core::Result<usize>, tokio::task::JoinError>,
158) -> axum::response::Response {
159 match result {
160 Ok(Ok(inserted)) => {
161 #[allow(deprecated)]
165 state.db.notify_upsert(name, inserted);
166 Json(serde_json::json!({
167 "message": "Points upserted",
168 "count": inserted
169 }))
170 .into_response()
171 }
172 Ok(Err(e)) => auto_core_error_response(&e),
173 Err(e) => error_response(
174 StatusCode::INTERNAL_SERVER_ERROR,
175 format!("Task panicked: {e}"),
176 ),
177 }
178}
179
180fn build_points_from_request(req: UpsertPointsRequest) -> Result<Vec<Point>, String> {
182 req.points
183 .into_iter()
184 .map(|p| {
185 let sparse = convert_sparse_inputs(p.sparse_vector, p.sparse_vectors)?;
186 let mut point = Point::new(p.id, p.vector, p.payload);
187 point.sparse_vectors = sparse;
188 Ok(point)
189 })
190 .collect()
191}
192
193#[utoipa::path(
195 get,
196 path = "/collections/{name}/points/{id}",
197 tag = "points",
198 params(
199 ("name" = String, Path, description = "Collection name"),
200 ("id" = String, Path, description = "Point ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
201 ),
202 responses(
203 (status = 200, description = "Point found", body = Object),
204 (status = 404, description = "Point or collection not found", body = ErrorResponse)
205 )
206)]
207pub async fn get_point(
208 State(state): State<Arc<AppState>>,
209 Path((name, id)): Path<(String, u64)>,
210) -> impl IntoResponse {
211 let collection = match get_vector_collection_or_404(&state, &name) {
212 Ok(c) => c,
213 Err(resp) => return resp,
214 };
215
216 let points = match run_blocking(move || collection.get(&[id])).await {
218 Ok(p) => p,
219 Err(resp) => return resp,
220 };
221
222 match points.into_iter().next().flatten() {
223 Some(point) => Json(serde_json::json!({
226 "id": point.id.to_string(),
227 "vector": point.vector,
228 "payload": point.payload
229 }))
230 .into_response(),
231 None => auto_core_error_response(&velesdb_core::Error::PointNotFound(id)),
235 }
236}
237
238#[utoipa::path(
240 delete,
241 path = "/collections/{name}/points/{id}",
242 tag = "points",
243 params(
244 ("name" = String, Path, description = "Collection name"),
245 ("id" = String, Path, description = "Point ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
246 ),
247 responses(
248 (status = 200, description = "Point deleted", body = Object),
249 (status = 404, description = "Point or collection not found", body = ErrorResponse)
250 )
251)]
252pub async fn delete_point(
253 State(state): State<Arc<AppState>>,
254 Path((name, id)): Path<(String, u64)>,
255) -> impl IntoResponse {
256 let collection = match get_vector_collection_or_404(&state, &name) {
257 Ok(c) => c,
258 Err(resp) => return resp,
259 };
260
261 match run_blocking(move || collection.delete(&[id])).await {
263 Ok(Ok(())) => Json(serde_json::json!({
266 "message": "Point deleted",
267 "id": id.to_string()
268 }))
269 .into_response(),
270 Ok(Err(e)) => auto_core_error_response(&e),
271 Err(resp) => resp,
272 }
273}
274
275const MAX_SCROLL_BATCH_SIZE: u32 = 10_000;
277
278#[utoipa::path(
280 post,
281 path = "/collections/{name}/points/scroll",
282 tag = "points",
283 params(("name" = String, Path, description = "Collection name")),
284 request_body = ScrollRequest,
285 responses(
286 (status = 200, description = "Scroll batch", body = ScrollResponse),
287 (status = 400, description = "Invalid request", body = ErrorResponse),
288 (status = 404, description = "Collection not found", body = ErrorResponse),
289 (status = 422, response = crate::types::MalformedBody)
290 )
291)]
292pub async fn scroll_points(
293 State(state): State<Arc<AppState>>,
294 Path(name): Path<String>,
295 Json(req): Json<ScrollRequest>,
296) -> impl IntoResponse {
297 if req.batch_size == 0 || req.batch_size > MAX_SCROLL_BATCH_SIZE {
298 return error_response(
299 StatusCode::BAD_REQUEST,
300 "batch_size must be between 1 and 10000".to_string(),
301 );
302 }
303
304 let collection = match get_vector_collection_or_404(&state, &name) {
305 Ok(c) => c,
306 Err(resp) => return resp,
307 };
308
309 let filter = match parse_scroll_filter(&req.filter) {
310 Ok(f) => f,
311 Err(resp) => return resp,
312 };
313
314 let batch_size = req.batch_size as usize;
315 let cursor = req.cursor;
316
317 let result = tokio::task::spawn_blocking(move || {
319 collection.scroll_batch(cursor, batch_size, filter.as_ref())
320 })
321 .await;
322
323 match result {
324 Ok(Ok(batch)) => build_scroll_response(batch),
325 Ok(Err(e)) => auto_core_error_response(&e),
326 Err(e) => error_response(
327 StatusCode::INTERNAL_SERVER_ERROR,
328 format!("Task panicked: {e}"),
329 ),
330 }
331}
332
333#[allow(clippy::result_large_err)]
335fn parse_scroll_filter(
336 filter_json: &Option<serde_json::Value>,
337) -> Result<Option<velesdb_core::Filter>, axum::response::Response> {
338 let Some(ref json) = filter_json else {
339 return Ok(None);
340 };
341 serde_json::from_value::<velesdb_core::Filter>(json.clone())
342 .map(Some)
343 .map_err(|e| error_response(StatusCode::BAD_REQUEST, format!("Invalid filter: {e}")))
344}
345
346fn build_scroll_response(batch: velesdb_core::ScrollBatch) -> axum::response::Response {
348 let points: Vec<ScrollPoint> = batch
349 .points
350 .into_iter()
351 .map(|p| ScrollPoint {
352 id: p.id,
353 vector: p.vector,
354 payload: p.payload,
355 })
356 .collect();
357 Json(ScrollResponse {
358 next_cursor: batch.next_cursor,
359 points,
360 })
361 .into_response()
362}
363
364const MAX_BULK_DELETE_SIZE: usize = 10_000;
366
367#[derive(serde::Deserialize, utoipa::ToSchema)]
369pub struct BulkDeleteRequest {
370 #[serde(deserialize_with = "serde_id::deserialize_ids_from_string_or_number")]
372 #[cfg_attr(feature = "openapi", schema(schema_with = serde_id::ids_array_schema))]
373 pub ids: Vec<u64>,
374}
375
376#[utoipa::path(
397 post,
398 path = "/collections/{name}/points/delete",
399 tag = "points",
400 params(
401 ("name" = String, Path, description = "Collection name")
402 ),
403 request_body = BulkDeleteRequest,
404 responses(
405 (status = 200, description = "Points deleted", body = Object),
406 (status = 400, description = "Batch too large", body = ErrorResponse),
407 (status = 404, description = "Collection not found", body = ErrorResponse),
408 (status = 500, description = "Delete failed", body = ErrorResponse),
409 (status = 422, response = crate::types::MalformedBody)
410 )
411)]
412pub async fn bulk_delete_points(
413 State(state): State<Arc<AppState>>,
414 Path(name): Path<String>,
415 Json(req): Json<BulkDeleteRequest>,
416) -> impl IntoResponse {
417 if req.ids.is_empty() {
418 return Json(serde_json::json!({
419 "message": "No points to delete",
420 "collection": name,
421 "deleted_count": 0
422 }))
423 .into_response();
424 }
425
426 if req.ids.len() > MAX_BULK_DELETE_SIZE {
427 return error_response(
428 StatusCode::BAD_REQUEST,
429 format!(
430 "Batch too large: {} IDs (max {MAX_BULK_DELETE_SIZE})",
431 req.ids.len()
432 ),
433 );
434 }
435
436 let collection = match get_vector_collection_or_404(&state, &name) {
437 Ok(c) => c,
438 Err(resp) => return resp,
439 };
440
441 let ids = req.ids;
442 let count = ids.len();
443 let coll_name = name.clone();
444
445 let result = tokio::task::spawn_blocking(move || collection.delete(&ids)).await;
446 match result {
447 Ok(Ok(())) => Json(serde_json::json!({
448 "message": "Points deleted",
449 "collection": coll_name,
450 "deleted_count": count
451 }))
452 .into_response(),
453 Ok(Err(e)) => auto_core_error_response(&e),
454 Err(join_err) => error_response(
455 StatusCode::INTERNAL_SERVER_ERROR,
456 format!("bulk_delete task panicked: {join_err}"),
457 ),
458 }
459}
460
461#[cfg(test)]
462#[path = "mod_tests.rs"]
463mod tests;