ruvector_node/
lib.rs

1//! Node.js bindings for Ruvector via NAPI-RS
2//!
3//! High-performance Rust vector database with zero-copy buffer sharing,
4//! async/await support, and complete TypeScript type definitions.
5
6#![deny(clippy::all)]
7#![warn(clippy::pedantic)]
8
9use napi::bindgen_prelude::*;
10use napi_derive::napi;
11use ruvector_core::{
12    types::{DbOptions, HnswConfig, QuantizationConfig},
13    DistanceMetric, SearchQuery, SearchResult, VectorDB as CoreVectorDB, VectorEntry,
14};
15use std::sync::Arc;
16use std::sync::RwLock;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19// Import new crates
20use ruvector_collections::CollectionManager as CoreCollectionManager;
21use ruvector_filter::FilterExpression;
22use ruvector_metrics::{gather_metrics, HealthChecker, HealthStatus};
23use std::path::PathBuf;
24
25/// Distance metric for similarity calculation
26#[napi(string_enum)]
27#[derive(Debug)]
28pub enum JsDistanceMetric {
29    /// Euclidean (L2) distance
30    Euclidean,
31    /// Cosine similarity (converted to distance)
32    Cosine,
33    /// Dot product (converted to distance for maximization)
34    DotProduct,
35    /// Manhattan (L1) distance
36    Manhattan,
37}
38
39impl From<JsDistanceMetric> for DistanceMetric {
40    fn from(metric: JsDistanceMetric) -> Self {
41        match metric {
42            JsDistanceMetric::Euclidean => DistanceMetric::Euclidean,
43            JsDistanceMetric::Cosine => DistanceMetric::Cosine,
44            JsDistanceMetric::DotProduct => DistanceMetric::DotProduct,
45            JsDistanceMetric::Manhattan => DistanceMetric::Manhattan,
46        }
47    }
48}
49
50/// Quantization configuration
51#[napi(object)]
52#[derive(Debug, Clone)]
53pub struct JsQuantizationConfig {
54    /// Quantization type: "none", "scalar", "product", "binary"
55    pub r#type: String,
56    /// Number of subspaces (for product quantization)
57    pub subspaces: Option<u32>,
58    /// Codebook size (for product quantization)
59    pub k: Option<u32>,
60}
61
62impl From<JsQuantizationConfig> for QuantizationConfig {
63    fn from(config: JsQuantizationConfig) -> Self {
64        match config.r#type.as_str() {
65            "none" => QuantizationConfig::None,
66            "scalar" => QuantizationConfig::Scalar,
67            "product" => QuantizationConfig::Product {
68                subspaces: config.subspaces.unwrap_or(16) as usize,
69                k: config.k.unwrap_or(256) as usize,
70            },
71            "binary" => QuantizationConfig::Binary,
72            _ => QuantizationConfig::Scalar,
73        }
74    }
75}
76
77/// HNSW index configuration
78#[napi(object)]
79#[derive(Debug, Clone)]
80pub struct JsHnswConfig {
81    /// Number of connections per layer (M)
82    pub m: Option<u32>,
83    /// Size of dynamic candidate list during construction
84    pub ef_construction: Option<u32>,
85    /// Size of dynamic candidate list during search
86    pub ef_search: Option<u32>,
87    /// Maximum number of elements
88    pub max_elements: Option<u32>,
89}
90
91impl From<JsHnswConfig> for HnswConfig {
92    fn from(config: JsHnswConfig) -> Self {
93        HnswConfig {
94            m: config.m.unwrap_or(32) as usize,
95            ef_construction: config.ef_construction.unwrap_or(200) as usize,
96            ef_search: config.ef_search.unwrap_or(100) as usize,
97            max_elements: config.max_elements.unwrap_or(10_000_000) as usize,
98        }
99    }
100}
101
102/// Database configuration options
103#[napi(object)]
104#[derive(Debug)]
105pub struct JsDbOptions {
106    /// Vector dimensions
107    pub dimensions: u32,
108    /// Distance metric
109    pub distance_metric: Option<JsDistanceMetric>,
110    /// Storage path
111    pub storage_path: Option<String>,
112    /// HNSW configuration
113    pub hnsw_config: Option<JsHnswConfig>,
114    /// Quantization configuration
115    pub quantization: Option<JsQuantizationConfig>,
116}
117
118impl From<JsDbOptions> for DbOptions {
119    fn from(options: JsDbOptions) -> Self {
120        DbOptions {
121            dimensions: options.dimensions as usize,
122            distance_metric: options
123                .distance_metric
124                .map(Into::into)
125                .unwrap_or(DistanceMetric::Cosine),
126            storage_path: options
127                .storage_path
128                .unwrap_or_else(|| "./ruvector.db".to_string()),
129            hnsw_config: options.hnsw_config.map(Into::into),
130            quantization: options.quantization.map(Into::into),
131        }
132    }
133}
134
135/// Vector entry
136#[napi(object)]
137pub struct JsVectorEntry {
138    /// Optional ID (auto-generated if not provided)
139    pub id: Option<String>,
140    /// Vector data as Float32Array or array of numbers
141    pub vector: Float32Array,
142}
143
144impl JsVectorEntry {
145    fn to_core(&self) -> Result<VectorEntry> {
146        Ok(VectorEntry {
147            id: self.id.clone(),
148            vector: self.vector.to_vec(),
149            metadata: None,
150        })
151    }
152}
153
154/// Search query parameters
155#[napi(object)]
156pub struct JsSearchQuery {
157    /// Query vector as Float32Array or array of numbers
158    pub vector: Float32Array,
159    /// Number of results to return (top-k)
160    pub k: u32,
161    /// Optional ef_search parameter for HNSW
162    pub ef_search: Option<u32>,
163}
164
165impl JsSearchQuery {
166    fn to_core(&self) -> Result<SearchQuery> {
167        Ok(SearchQuery {
168            vector: self.vector.to_vec(),
169            k: self.k as usize,
170            filter: None,
171            ef_search: self.ef_search.map(|v| v as usize),
172        })
173    }
174}
175
176/// Search result with similarity score
177#[napi(object)]
178#[derive(Debug, Clone)]
179pub struct JsSearchResult {
180    /// Vector ID
181    pub id: String,
182    /// Distance/similarity score (lower is better for distance metrics)
183    pub score: f64,
184}
185
186impl From<SearchResult> for JsSearchResult {
187    fn from(result: SearchResult) -> Self {
188        JsSearchResult {
189            id: result.id,
190            score: f64::from(result.score),
191        }
192    }
193}
194
195/// High-performance vector database with HNSW indexing
196#[napi]
197pub struct VectorDB {
198    inner: Arc<RwLock<CoreVectorDB>>,
199}
200
201#[napi]
202impl VectorDB {
203    /// Create a new vector database with the given options
204    ///
205    /// # Example
206    /// ```javascript
207    /// const db = new VectorDB({
208    ///   dimensions: 384,
209    ///   distanceMetric: 'Cosine',
210    ///   storagePath: './vectors.db',
211    ///   hnswConfig: {
212    ///     m: 32,
213    ///     efConstruction: 200,
214    ///     efSearch: 100
215    ///   }
216    /// });
217    /// ```
218    #[napi(constructor)]
219    pub fn new(options: JsDbOptions) -> Result<Self> {
220        let core_options: DbOptions = options.into();
221        let db = CoreVectorDB::new(core_options)
222            .map_err(|e| Error::from_reason(format!("Failed to create database: {}", e)))?;
223
224        Ok(Self {
225            inner: Arc::new(RwLock::new(db)),
226        })
227    }
228
229    /// Create a vector database with default options
230    ///
231    /// # Example
232    /// ```javascript
233    /// const db = VectorDB.withDimensions(384);
234    /// ```
235    #[napi(factory)]
236    pub fn with_dimensions(dimensions: u32) -> Result<Self> {
237        let db = CoreVectorDB::with_dimensions(dimensions as usize)
238            .map_err(|e| Error::from_reason(format!("Failed to create database: {}", e)))?;
239
240        Ok(Self {
241            inner: Arc::new(RwLock::new(db)),
242        })
243    }
244
245    /// Insert a vector entry into the database
246    ///
247    /// Returns the ID of the inserted vector (auto-generated if not provided)
248    ///
249    /// # Example
250    /// ```javascript
251    /// const id = await db.insert({
252    ///   vector: new Float32Array([1.0, 2.0, 3.0]),
253    ///   metadata: { text: 'example' }
254    /// });
255    /// ```
256    #[napi]
257    pub async fn insert(&self, entry: JsVectorEntry) -> Result<String> {
258        let core_entry = entry.to_core()?;
259        let db = self.inner.clone();
260
261        tokio::task::spawn_blocking(move || {
262            let db = db.read().expect("RwLock poisoned");
263            db.insert(core_entry)
264        })
265        .await
266        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
267        .map_err(|e| Error::from_reason(format!("Insert failed: {}", e)))
268    }
269
270    /// Insert multiple vectors in a batch
271    ///
272    /// Returns an array of IDs for the inserted vectors
273    ///
274    /// # Example
275    /// ```javascript
276    /// const ids = await db.insertBatch([
277    ///   { vector: new Float32Array([1, 2, 3]) },
278    ///   { vector: new Float32Array([4, 5, 6]) }
279    /// ]);
280    /// ```
281    #[napi]
282    pub async fn insert_batch(&self, entries: Vec<JsVectorEntry>) -> Result<Vec<String>> {
283        let core_entries: Result<Vec<VectorEntry>> = entries.iter().map(|e| e.to_core()).collect();
284        let core_entries = core_entries?;
285        let db = self.inner.clone();
286
287        tokio::task::spawn_blocking(move || {
288            let db = db.read().expect("RwLock poisoned");
289            db.insert_batch(core_entries)
290        })
291        .await
292        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
293        .map_err(|e| Error::from_reason(format!("Batch insert failed: {}", e)))
294    }
295
296    /// Search for similar vectors
297    ///
298    /// Returns an array of search results sorted by similarity
299    ///
300    /// # Example
301    /// ```javascript
302    /// const results = await db.search({
303    ///   vector: new Float32Array([1, 2, 3]),
304    ///   k: 10,
305    ///   filter: { category: 'example' }
306    /// });
307    /// ```
308    #[napi]
309    pub async fn search(&self, query: JsSearchQuery) -> Result<Vec<JsSearchResult>> {
310        let core_query = query.to_core()?;
311        let db = self.inner.clone();
312
313        tokio::task::spawn_blocking(move || {
314            let db = db.read().expect("RwLock poisoned");
315            db.search(core_query)
316        })
317        .await
318        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
319        .map_err(|e| Error::from_reason(format!("Search failed: {}", e)))
320        .map(|results| results.into_iter().map(Into::into).collect())
321    }
322
323    /// Delete a vector by ID
324    ///
325    /// Returns true if the vector was deleted, false if not found
326    ///
327    /// # Example
328    /// ```javascript
329    /// const deleted = await db.delete('vector-id');
330    /// ```
331    #[napi]
332    pub async fn delete(&self, id: String) -> Result<bool> {
333        let db = self.inner.clone();
334
335        tokio::task::spawn_blocking(move || {
336            let db = db.read().expect("RwLock poisoned");
337            db.delete(&id)
338        })
339        .await
340        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
341        .map_err(|e| Error::from_reason(format!("Delete failed: {}", e)))
342    }
343
344    /// Get a vector by ID
345    ///
346    /// Returns the vector entry if found, null otherwise
347    ///
348    /// # Example
349    /// ```javascript
350    /// const entry = await db.get('vector-id');
351    /// if (entry) {
352    ///   console.log('Found:', entry.metadata);
353    /// }
354    /// ```
355    #[napi]
356    pub async fn get(&self, id: String) -> Result<Option<JsVectorEntry>> {
357        let db = self.inner.clone();
358
359        let result = tokio::task::spawn_blocking(move || {
360            let db = db.read().expect("RwLock poisoned");
361            db.get(&id)
362        })
363        .await
364        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
365        .map_err(|e| Error::from_reason(format!("Get failed: {}", e)))?;
366
367        Ok(result.map(|entry| JsVectorEntry {
368            id: entry.id,
369            vector: Float32Array::new(entry.vector),
370        }))
371    }
372
373    /// Get the number of vectors in the database
374    ///
375    /// # Example
376    /// ```javascript
377    /// const count = await db.len();
378    /// console.log(`Database contains ${count} vectors`);
379    /// ```
380    #[napi]
381    pub async fn len(&self) -> Result<u32> {
382        let db = self.inner.clone();
383
384        tokio::task::spawn_blocking(move || {
385            let db = db.read().expect("RwLock poisoned");
386            db.len()
387        })
388        .await
389        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
390        .map_err(|e| Error::from_reason(format!("Len failed: {}", e)))
391        .map(|len| len as u32)
392    }
393
394    /// Check if the database is empty
395    ///
396    /// # Example
397    /// ```javascript
398    /// if (await db.isEmpty()) {
399    ///   console.log('Database is empty');
400    /// }
401    /// ```
402    #[napi]
403    pub async fn is_empty(&self) -> Result<bool> {
404        let db = self.inner.clone();
405
406        tokio::task::spawn_blocking(move || {
407            let db = db.read().expect("RwLock poisoned");
408            db.is_empty()
409        })
410        .await
411        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
412        .map_err(|e| Error::from_reason(format!("IsEmpty failed: {}", e)))
413    }
414}
415
416/// Get the version of the Ruvector library
417#[napi]
418pub fn version() -> String {
419    env!("CARGO_PKG_VERSION").to_string()
420}
421
422/// Test function to verify the bindings are working
423#[napi]
424pub fn hello() -> String {
425    "Hello from Ruvector Node.js bindings!".to_string()
426}
427
428/// Filter for metadata-based search
429#[napi(object)]
430#[derive(Debug, Clone)]
431pub struct JsFilter {
432    /// Field name to filter on
433    pub field: String,
434    /// Operator: "eq", "ne", "gt", "gte", "lt", "lte", "in", "match"
435    pub operator: String,
436    /// Value to compare against (JSON string)
437    pub value: String,
438}
439
440impl JsFilter {
441    fn to_filter_expression(&self) -> Result<FilterExpression> {
442        let value: serde_json::Value = serde_json::from_str(&self.value)
443            .map_err(|e| Error::from_reason(format!("Invalid JSON value: {}", e)))?;
444
445        Ok(match self.operator.as_str() {
446            "eq" => FilterExpression::eq(&self.field, value),
447            "ne" => FilterExpression::ne(&self.field, value),
448            "gt" => FilterExpression::gt(&self.field, value),
449            "gte" => FilterExpression::gte(&self.field, value),
450            "lt" => FilterExpression::lt(&self.field, value),
451            "lte" => FilterExpression::lte(&self.field, value),
452            "match" => FilterExpression::Match {
453                field: self.field.clone(),
454                text: value.as_str().unwrap_or("").to_string(),
455            },
456            _ => FilterExpression::eq(&self.field, value),
457        })
458    }
459}
460
461/// Collection configuration
462#[napi(object)]
463#[derive(Debug, Clone)]
464pub struct JsCollectionConfig {
465    /// Vector dimensions
466    pub dimensions: u32,
467    /// Distance metric
468    pub distance_metric: Option<JsDistanceMetric>,
469    /// HNSW configuration
470    pub hnsw_config: Option<JsHnswConfig>,
471    /// Quantization configuration
472    pub quantization: Option<JsQuantizationConfig>,
473}
474
475impl From<JsCollectionConfig> for ruvector_collections::CollectionConfig {
476    fn from(config: JsCollectionConfig) -> Self {
477        ruvector_collections::CollectionConfig {
478            dimensions: config.dimensions as usize,
479            distance_metric: config
480                .distance_metric
481                .map(Into::into)
482                .unwrap_or(DistanceMetric::Cosine),
483            hnsw_config: config.hnsw_config.map(Into::into),
484            quantization: config.quantization.map(Into::into),
485            on_disk_payload: true,
486        }
487    }
488}
489
490/// Collection statistics
491#[napi(object)]
492#[derive(Debug, Clone)]
493pub struct JsCollectionStats {
494    /// Number of vectors in the collection
495    pub vectors_count: u32,
496    /// Disk space used in bytes
497    pub disk_size_bytes: i64,
498    /// RAM space used in bytes
499    pub ram_size_bytes: i64,
500}
501
502impl From<ruvector_collections::CollectionStats> for JsCollectionStats {
503    fn from(stats: ruvector_collections::CollectionStats) -> Self {
504        JsCollectionStats {
505            vectors_count: stats.vectors_count as u32,
506            disk_size_bytes: stats.disk_size_bytes as i64,
507            ram_size_bytes: stats.ram_size_bytes as i64,
508        }
509    }
510}
511
512/// Collection alias
513#[napi(object)]
514#[derive(Debug, Clone)]
515pub struct JsAlias {
516    /// Alias name
517    pub alias: String,
518    /// Collection name
519    pub collection: String,
520}
521
522impl From<(String, String)> for JsAlias {
523    fn from(tuple: (String, String)) -> Self {
524        JsAlias {
525            alias: tuple.0,
526            collection: tuple.1,
527        }
528    }
529}
530
531/// Collection manager for multi-collection support
532#[napi]
533pub struct CollectionManager {
534    inner: Arc<RwLock<CoreCollectionManager>>,
535}
536
537#[napi]
538impl CollectionManager {
539    /// Create a new collection manager
540    ///
541    /// # Example
542    /// ```javascript
543    /// const manager = new CollectionManager('./collections');
544    /// ```
545    #[napi(constructor)]
546    pub fn new(base_path: Option<String>) -> Result<Self> {
547        let path = PathBuf::from(base_path.unwrap_or_else(|| "./collections".to_string()));
548        let manager = CoreCollectionManager::new(path).map_err(|e| {
549            Error::from_reason(format!("Failed to create collection manager: {}", e))
550        })?;
551
552        Ok(Self {
553            inner: Arc::new(RwLock::new(manager)),
554        })
555    }
556
557    /// Create a new collection
558    ///
559    /// # Example
560    /// ```javascript
561    /// await manager.createCollection('my_vectors', {
562    ///   dimensions: 384,
563    ///   distanceMetric: 'Cosine'
564    /// });
565    /// ```
566    #[napi]
567    pub async fn create_collection(&self, name: String, config: JsCollectionConfig) -> Result<()> {
568        let core_config: ruvector_collections::CollectionConfig = config.into();
569        let manager = self.inner.clone();
570
571        tokio::task::spawn_blocking(move || {
572            let manager = manager.write().expect("RwLock poisoned");
573            manager.create_collection(&name, core_config)
574        })
575        .await
576        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
577        .map_err(|e| Error::from_reason(format!("Failed to create collection: {}", e)))
578    }
579
580    /// List all collections
581    ///
582    /// # Example
583    /// ```javascript
584    /// const collections = await manager.listCollections();
585    /// console.log('Collections:', collections);
586    /// ```
587    #[napi]
588    pub async fn list_collections(&self) -> Result<Vec<String>> {
589        let manager = self.inner.clone();
590
591        tokio::task::spawn_blocking(move || {
592            let manager = manager.read().expect("RwLock poisoned");
593            manager.list_collections()
594        })
595        .await
596        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))
597    }
598
599    /// Delete a collection
600    ///
601    /// # Example
602    /// ```javascript
603    /// await manager.deleteCollection('my_vectors');
604    /// ```
605    #[napi]
606    pub async fn delete_collection(&self, name: String) -> Result<()> {
607        let manager = self.inner.clone();
608
609        tokio::task::spawn_blocking(move || {
610            let manager = manager.write().expect("RwLock poisoned");
611            manager.delete_collection(&name)
612        })
613        .await
614        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
615        .map_err(|e| Error::from_reason(format!("Failed to delete collection: {}", e)))
616    }
617
618    /// Get collection statistics
619    ///
620    /// # Example
621    /// ```javascript
622    /// const stats = await manager.getStats('my_vectors');
623    /// console.log(`Vectors: ${stats.vectorsCount}`);
624    /// ```
625    #[napi]
626    pub async fn get_stats(&self, name: String) -> Result<JsCollectionStats> {
627        let manager = self.inner.clone();
628
629        tokio::task::spawn_blocking(move || {
630            let manager = manager.read().expect("RwLock poisoned");
631            manager.collection_stats(&name)
632        })
633        .await
634        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
635        .map_err(|e| Error::from_reason(format!("Failed to get stats: {}", e)))
636        .map(Into::into)
637    }
638
639    /// Create an alias for a collection
640    ///
641    /// # Example
642    /// ```javascript
643    /// await manager.createAlias('latest', 'my_vectors_v2');
644    /// ```
645    #[napi]
646    pub async fn create_alias(&self, alias: String, collection: String) -> Result<()> {
647        let manager = self.inner.clone();
648
649        tokio::task::spawn_blocking(move || {
650            let manager = manager.write().expect("RwLock poisoned");
651            manager.create_alias(&alias, &collection)
652        })
653        .await
654        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
655        .map_err(|e| Error::from_reason(format!("Failed to create alias: {}", e)))
656    }
657
658    /// Delete an alias
659    ///
660    /// # Example
661    /// ```javascript
662    /// await manager.deleteAlias('latest');
663    /// ```
664    #[napi]
665    pub async fn delete_alias(&self, alias: String) -> Result<()> {
666        let manager = self.inner.clone();
667
668        tokio::task::spawn_blocking(move || {
669            let manager = manager.write().expect("RwLock poisoned");
670            manager.delete_alias(&alias)
671        })
672        .await
673        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
674        .map_err(|e| Error::from_reason(format!("Failed to delete alias: {}", e)))
675    }
676
677    /// List all aliases
678    ///
679    /// # Example
680    /// ```javascript
681    /// const aliases = await manager.listAliases();
682    /// for (const alias of aliases) {
683    ///   console.log(`${alias.alias} -> ${alias.collection}`);
684    /// }
685    /// ```
686    #[napi]
687    pub async fn list_aliases(&self) -> Result<Vec<JsAlias>> {
688        let manager = self.inner.clone();
689
690        let aliases = tokio::task::spawn_blocking(move || {
691            let manager = manager.read().expect("RwLock poisoned");
692            manager.list_aliases()
693        })
694        .await
695        .map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?;
696
697        Ok(aliases.into_iter().map(Into::into).collect())
698    }
699}
700
701/// Health response
702#[napi(object)]
703#[derive(Debug, Clone)]
704pub struct JsHealthResponse {
705    /// Status: "healthy", "degraded", or "unhealthy"
706    pub status: String,
707    /// Version string
708    pub version: String,
709    /// Uptime in seconds
710    pub uptime_seconds: i64,
711}
712
713/// Get Prometheus metrics
714///
715/// # Example
716/// ```javascript
717/// const metrics = getMetrics();
718/// console.log(metrics);
719/// ```
720#[napi]
721pub fn get_metrics() -> String {
722    gather_metrics()
723}
724
725/// Get health status
726///
727/// # Example
728/// ```javascript
729/// const health = getHealth();
730/// console.log(`Status: ${health.status}`);
731/// console.log(`Uptime: ${health.uptimeSeconds}s`);
732/// ```
733#[napi]
734pub fn get_health() -> JsHealthResponse {
735    let checker = HealthChecker::new();
736    let health = checker.health();
737
738    JsHealthResponse {
739        status: match health.status {
740            HealthStatus::Healthy => "healthy".to_string(),
741            HealthStatus::Degraded => "degraded".to_string(),
742            HealthStatus::Unhealthy => "unhealthy".to_string(),
743        },
744        version: health.version,
745        uptime_seconds: health.uptime_seconds as i64,
746    }
747}