#![deny(clippy::all)]
#![warn(clippy::pedantic)]
use napi::bindgen_prelude::*;
use napi_derive::napi;
use ruvector_core::{
types::{DbOptions, HnswConfig, QuantizationConfig},
DistanceMetric, SearchQuery, SearchResult, VectorDB as CoreVectorDB, VectorEntry,
};
use std::sync::Arc;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use ruvector_collections::CollectionManager as CoreCollectionManager;
use ruvector_filter::FilterExpression;
use ruvector_metrics::{gather_metrics, HealthChecker, HealthStatus};
use std::path::PathBuf;
#[napi(string_enum)]
#[derive(Debug)]
pub enum JsDistanceMetric {
Euclidean,
Cosine,
DotProduct,
Manhattan,
}
impl From<JsDistanceMetric> for DistanceMetric {
fn from(metric: JsDistanceMetric) -> Self {
match metric {
JsDistanceMetric::Euclidean => DistanceMetric::Euclidean,
JsDistanceMetric::Cosine => DistanceMetric::Cosine,
JsDistanceMetric::DotProduct => DistanceMetric::DotProduct,
JsDistanceMetric::Manhattan => DistanceMetric::Manhattan,
}
}
}
#[napi(object)]
#[derive(Debug, Clone)]
pub struct JsQuantizationConfig {
pub r#type: String,
pub subspaces: Option<u32>,
pub k: Option<u32>,
}
impl From<JsQuantizationConfig> for QuantizationConfig {
fn from(config: JsQuantizationConfig) -> Self {
match config.r#type.as_str() {
"none" => QuantizationConfig::None,
"scalar" => QuantizationConfig::Scalar,
"product" => QuantizationConfig::Product {
subspaces: config.subspaces.unwrap_or(16) as usize,
k: config.k.unwrap_or(256) as usize,
},
"binary" => QuantizationConfig::Binary,
_ => QuantizationConfig::Scalar,
}
}
}
#[napi(object)]
#[derive(Debug, Clone)]
pub struct JsHnswConfig {
pub m: Option<u32>,
pub ef_construction: Option<u32>,
pub ef_search: Option<u32>,
pub max_elements: Option<u32>,
}
impl From<JsHnswConfig> for HnswConfig {
fn from(config: JsHnswConfig) -> Self {
HnswConfig {
m: config.m.unwrap_or(32) as usize,
ef_construction: config.ef_construction.unwrap_or(200) as usize,
ef_search: config.ef_search.unwrap_or(100) as usize,
max_elements: config.max_elements.unwrap_or(10_000_000) as usize,
}
}
}
#[napi(object)]
#[derive(Debug)]
pub struct JsDbOptions {
pub dimensions: u32,
pub distance_metric: Option<JsDistanceMetric>,
pub storage_path: Option<String>,
pub hnsw_config: Option<JsHnswConfig>,
pub quantization: Option<JsQuantizationConfig>,
}
impl From<JsDbOptions> for DbOptions {
fn from(options: JsDbOptions) -> Self {
DbOptions {
dimensions: options.dimensions as usize,
distance_metric: options
.distance_metric
.map(Into::into)
.unwrap_or(DistanceMetric::Cosine),
storage_path: options
.storage_path
.unwrap_or_else(|| "./ruvector.db".to_string()),
hnsw_config: options.hnsw_config.map(Into::into),
quantization: options.quantization.map(Into::into),
}
}
}
#[napi(object)]
pub struct JsVectorEntry {
pub id: Option<String>,
pub vector: Float32Array,
pub metadata: Option<String>,
}
impl JsVectorEntry {
fn to_core(&self) -> Result<VectorEntry> {
let metadata = self.metadata.as_ref().and_then(|s| {
serde_json::from_str::<std::collections::HashMap<String, serde_json::Value>>(s).ok()
});
Ok(VectorEntry {
id: self.id.clone(),
vector: self.vector.to_vec(),
metadata,
})
}
}
#[napi(object)]
pub struct JsSearchQuery {
pub vector: Float32Array,
pub k: u32,
pub ef_search: Option<u32>,
pub filter: Option<String>,
}
impl JsSearchQuery {
fn to_core(&self) -> Result<SearchQuery> {
let filter = self.filter.as_ref().and_then(|s| {
serde_json::from_str::<std::collections::HashMap<String, serde_json::Value>>(s).ok()
});
Ok(SearchQuery {
vector: self.vector.to_vec(),
k: self.k as usize,
filter,
ef_search: self.ef_search.map(|v| v as usize),
})
}
}
#[napi(object)]
#[derive(Clone)]
pub struct JsSearchResult {
pub id: String,
pub score: f64,
pub vector: Option<Float32Array>,
pub metadata: Option<String>,
}
impl From<SearchResult> for JsSearchResult {
fn from(result: SearchResult) -> Self {
let vector = result.vector.map(|v| Float32Array::new(v));
let metadata = result.metadata.and_then(|m| serde_json::to_string(&m).ok());
JsSearchResult {
id: result.id,
score: f64::from(result.score),
vector,
metadata,
}
}
}
#[napi]
pub struct VectorDB {
inner: Arc<RwLock<CoreVectorDB>>,
}
#[napi]
impl VectorDB {
#[napi(constructor)]
pub fn new(options: JsDbOptions) -> Result<Self> {
let core_options: DbOptions = options.into();
let db = CoreVectorDB::new(core_options)
.map_err(|e| Error::from_reason(format!("Failed to create database: {}", e)))?;
Ok(Self {
inner: Arc::new(RwLock::new(db)),
})
}
#[napi(factory)]
pub fn with_dimensions(dimensions: u32) -> Result<Self> {
let db = CoreVectorDB::with_dimensions(dimensions as usize)
.map_err(|e| Error::from_reason(format!("Failed to create database: {}", e)))?;
Ok(Self {
inner: Arc::new(RwLock::new(db)),
})
}
#[napi]
pub async fn insert(&self, entry: JsVectorEntry) -> Result<String> {
let core_entry = entry.to_core()?;
let db = self.inner.clone();
tokio::task::spawn_blocking(move || {
let db = db.read().expect("RwLock poisoned");
db.insert(core_entry)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Insert failed: {}", e)))
}
#[napi]
pub async fn insert_batch(&self, entries: Vec<JsVectorEntry>) -> Result<Vec<String>> {
let core_entries: Result<Vec<VectorEntry>> = entries.iter().map(|e| e.to_core()).collect();
let core_entries = core_entries?;
let db = self.inner.clone();
tokio::task::spawn_blocking(move || {
let db = db.read().expect("RwLock poisoned");
db.insert_batch(core_entries)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Batch insert failed: {}", e)))
}
#[napi]
pub async fn search(&self, query: JsSearchQuery) -> Result<Vec<JsSearchResult>> {
let core_query = query.to_core()?;
let db = self.inner.clone();
tokio::task::spawn_blocking(move || {
let db = db.read().expect("RwLock poisoned");
db.search(core_query)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Search failed: {}", e)))
.map(|results| results.into_iter().map(Into::into).collect())
}
#[napi]
pub async fn delete(&self, id: String) -> Result<bool> {
let db = self.inner.clone();
tokio::task::spawn_blocking(move || {
let db = db.read().expect("RwLock poisoned");
db.delete(&id)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Delete failed: {}", e)))
}
#[napi]
pub async fn get(&self, id: String) -> Result<Option<JsVectorEntry>> {
let db = self.inner.clone();
let result = tokio::task::spawn_blocking(move || {
let db = db.read().expect("RwLock poisoned");
db.get(&id)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Get failed: {}", e)))?;
Ok(result.map(|entry| {
let metadata = entry.metadata.and_then(|m| serde_json::to_string(&m).ok());
JsVectorEntry {
id: entry.id,
vector: Float32Array::new(entry.vector),
metadata,
}
}))
}
#[napi]
pub async fn len(&self) -> Result<u32> {
let db = self.inner.clone();
tokio::task::spawn_blocking(move || {
let db = db.read().expect("RwLock poisoned");
db.len()
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Len failed: {}", e)))
.map(|len| len as u32)
}
#[napi]
pub async fn is_empty(&self) -> Result<bool> {
let db = self.inner.clone();
tokio::task::spawn_blocking(move || {
let db = db.read().expect("RwLock poisoned");
db.is_empty()
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("IsEmpty failed: {}", e)))
}
}
#[napi]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[napi]
pub fn hello() -> String {
"Hello from Ruvector Node.js bindings!".to_string()
}
#[napi(object)]
#[derive(Debug, Clone)]
pub struct JsFilter {
pub field: String,
pub operator: String,
pub value: String,
}
impl JsFilter {
fn to_filter_expression(&self) -> Result<FilterExpression> {
let value: serde_json::Value = serde_json::from_str(&self.value)
.map_err(|e| Error::from_reason(format!("Invalid JSON value: {}", e)))?;
Ok(match self.operator.as_str() {
"eq" => FilterExpression::eq(&self.field, value),
"ne" => FilterExpression::ne(&self.field, value),
"gt" => FilterExpression::gt(&self.field, value),
"gte" => FilterExpression::gte(&self.field, value),
"lt" => FilterExpression::lt(&self.field, value),
"lte" => FilterExpression::lte(&self.field, value),
"match" => FilterExpression::Match {
field: self.field.clone(),
text: value.as_str().unwrap_or("").to_string(),
},
_ => FilterExpression::eq(&self.field, value),
})
}
}
#[napi(object)]
#[derive(Debug, Clone)]
pub struct JsCollectionConfig {
pub dimensions: u32,
pub distance_metric: Option<JsDistanceMetric>,
pub hnsw_config: Option<JsHnswConfig>,
pub quantization: Option<JsQuantizationConfig>,
}
impl From<JsCollectionConfig> for ruvector_collections::CollectionConfig {
fn from(config: JsCollectionConfig) -> Self {
ruvector_collections::CollectionConfig {
dimensions: config.dimensions as usize,
distance_metric: config
.distance_metric
.map(Into::into)
.unwrap_or(DistanceMetric::Cosine),
hnsw_config: config.hnsw_config.map(Into::into),
quantization: config.quantization.map(Into::into),
on_disk_payload: true,
}
}
}
#[napi(object)]
#[derive(Debug, Clone)]
pub struct JsCollectionStats {
pub vectors_count: u32,
pub disk_size_bytes: i64,
pub ram_size_bytes: i64,
}
impl From<ruvector_collections::CollectionStats> for JsCollectionStats {
fn from(stats: ruvector_collections::CollectionStats) -> Self {
JsCollectionStats {
vectors_count: stats.vectors_count as u32,
disk_size_bytes: stats.disk_size_bytes as i64,
ram_size_bytes: stats.ram_size_bytes as i64,
}
}
}
#[napi(object)]
#[derive(Debug, Clone)]
pub struct JsAlias {
pub alias: String,
pub collection: String,
}
impl From<(String, String)> for JsAlias {
fn from(tuple: (String, String)) -> Self {
JsAlias {
alias: tuple.0,
collection: tuple.1,
}
}
}
#[napi]
pub struct CollectionManager {
inner: Arc<RwLock<CoreCollectionManager>>,
}
#[napi]
impl CollectionManager {
#[napi(constructor)]
pub fn new(base_path: Option<String>) -> Result<Self> {
let path = PathBuf::from(base_path.unwrap_or_else(|| "./collections".to_string()));
let manager = CoreCollectionManager::new(path).map_err(|e| {
Error::from_reason(format!("Failed to create collection manager: {}", e))
})?;
Ok(Self {
inner: Arc::new(RwLock::new(manager)),
})
}
#[napi]
pub async fn create_collection(&self, name: String, config: JsCollectionConfig) -> Result<()> {
let core_config: ruvector_collections::CollectionConfig = config.into();
let manager = self.inner.clone();
tokio::task::spawn_blocking(move || {
let manager = manager.write().expect("RwLock poisoned");
manager.create_collection(&name, core_config)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Failed to create collection: {}", e)))
}
#[napi]
pub async fn list_collections(&self) -> Result<Vec<String>> {
let manager = self.inner.clone();
tokio::task::spawn_blocking(move || {
let manager = manager.read().expect("RwLock poisoned");
manager.list_collections()
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))
}
#[napi]
pub async fn delete_collection(&self, name: String) -> Result<()> {
let manager = self.inner.clone();
tokio::task::spawn_blocking(move || {
let manager = manager.write().expect("RwLock poisoned");
manager.delete_collection(&name)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Failed to delete collection: {}", e)))
}
#[napi]
pub async fn get_stats(&self, name: String) -> Result<JsCollectionStats> {
let manager = self.inner.clone();
tokio::task::spawn_blocking(move || {
let manager = manager.read().expect("RwLock poisoned");
manager.collection_stats(&name)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Failed to get stats: {}", e)))
.map(Into::into)
}
#[napi]
pub async fn create_alias(&self, alias: String, collection: String) -> Result<()> {
let manager = self.inner.clone();
tokio::task::spawn_blocking(move || {
let manager = manager.write().expect("RwLock poisoned");
manager.create_alias(&alias, &collection)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Failed to create alias: {}", e)))
}
#[napi]
pub async fn delete_alias(&self, alias: String) -> Result<()> {
let manager = self.inner.clone();
tokio::task::spawn_blocking(move || {
let manager = manager.write().expect("RwLock poisoned");
manager.delete_alias(&alias)
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?
.map_err(|e| Error::from_reason(format!("Failed to delete alias: {}", e)))
}
#[napi]
pub async fn list_aliases(&self) -> Result<Vec<JsAlias>> {
let manager = self.inner.clone();
let aliases = tokio::task::spawn_blocking(move || {
let manager = manager.read().expect("RwLock poisoned");
manager.list_aliases()
})
.await
.map_err(|e| Error::from_reason(format!("Task failed: {}", e)))?;
Ok(aliases.into_iter().map(Into::into).collect())
}
}
#[napi(object)]
#[derive(Debug, Clone)]
pub struct JsHealthResponse {
pub status: String,
pub version: String,
pub uptime_seconds: i64,
}
#[napi]
pub fn get_metrics() -> String {
gather_metrics()
}
#[napi]
pub fn get_health() -> JsHealthResponse {
let checker = HealthChecker::new();
let health = checker.health();
JsHealthResponse {
status: match health.status {
HealthStatus::Healthy => "healthy".to_string(),
HealthStatus::Degraded => "degraded".to_string(),
HealthStatus::Unhealthy => "unhealthy".to_string(),
},
version: health.version,
uptime_seconds: health.uptime_seconds as i64,
}
}