ruvector_wasm/
lib.rs

1//! WASM bindings for Ruvector
2//!
3//! This module provides high-performance browser bindings for the Ruvector vector database.
4//! Features:
5//! - Full VectorDB API (insert, search, delete, batch operations)
6//! - SIMD acceleration (when available)
7//! - Web Workers support for parallel operations
8//! - IndexedDB persistence
9//! - Zero-copy transfers via transferable objects
10
11use js_sys::{Array, Float32Array, Object, Promise, Reflect, Uint8Array};
12use parking_lot::Mutex;
13#[cfg(feature = "collections")]
14use ruvector_collections::{
15    CollectionConfig as CoreCollectionConfig, CollectionManager as CoreCollectionManager,
16};
17use ruvector_core::{
18    error::RuvectorError,
19    types::{DbOptions, DistanceMetric, HnswConfig, SearchQuery, SearchResult, VectorEntry},
20    vector_db::VectorDB as CoreVectorDB,
21};
22#[cfg(feature = "collections")]
23use ruvector_filter::FilterExpression as CoreFilterExpression;
24use serde::{Deserialize, Serialize};
25use serde_wasm_bindgen::{from_value, to_value};
26use std::collections::HashMap;
27use std::sync::Arc;
28use wasm_bindgen::prelude::*;
29use wasm_bindgen_futures::JsFuture;
30use web_sys::{
31    console, IdbDatabase, IdbFactory, IdbObjectStore, IdbRequest, IdbTransaction, Window,
32};
33
34/// Initialize panic hook for better error messages in browser console
35#[wasm_bindgen(start)]
36pub fn init() {
37    console_error_panic_hook::set_once();
38    tracing_wasm::set_as_global_default();
39}
40
41/// WASM-specific error type that can cross the JS boundary
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct WasmError {
44    pub message: String,
45    pub kind: String,
46}
47
48impl From<RuvectorError> for WasmError {
49    fn from(err: RuvectorError) -> Self {
50        WasmError {
51            message: err.to_string(),
52            kind: format!("{:?}", err),
53        }
54    }
55}
56
57impl From<WasmError> for JsValue {
58    fn from(err: WasmError) -> Self {
59        let obj = Object::new();
60        Reflect::set(&obj, &"message".into(), &err.message.into()).unwrap();
61        Reflect::set(&obj, &"kind".into(), &err.kind.into()).unwrap();
62        obj.into()
63    }
64}
65
66type WasmResult<T> = Result<T, WasmError>;
67
68/// JavaScript-compatible VectorEntry
69#[wasm_bindgen]
70#[derive(Clone)]
71pub struct JsVectorEntry {
72    inner: VectorEntry,
73}
74
75/// Maximum allowed vector dimensions (security limit to prevent DoS)
76const MAX_VECTOR_DIMENSIONS: usize = 65536;
77
78#[wasm_bindgen]
79impl JsVectorEntry {
80    #[wasm_bindgen(constructor)]
81    pub fn new(
82        vector: Float32Array,
83        id: Option<String>,
84        metadata: Option<JsValue>,
85    ) -> Result<JsVectorEntry, JsValue> {
86        // Security: Validate vector dimensions before allocation
87        let vec_len = vector.length() as usize;
88        if vec_len == 0 {
89            return Err(JsValue::from_str("Vector cannot be empty"));
90        }
91        if vec_len > MAX_VECTOR_DIMENSIONS {
92            return Err(JsValue::from_str(&format!(
93                "Vector dimensions {} exceed maximum allowed {}",
94                vec_len, MAX_VECTOR_DIMENSIONS
95            )));
96        }
97
98        let vector_data: Vec<f32> = vector.to_vec();
99
100        let metadata = if let Some(meta) = metadata {
101            Some(
102                from_value(meta)
103                    .map_err(|e| JsValue::from_str(&format!("Invalid metadata: {}", e)))?,
104            )
105        } else {
106            None
107        };
108
109        Ok(JsVectorEntry {
110            inner: VectorEntry {
111                id,
112                vector: vector_data,
113                metadata,
114            },
115        })
116    }
117
118    #[wasm_bindgen(getter)]
119    pub fn id(&self) -> Option<String> {
120        self.inner.id.clone()
121    }
122
123    #[wasm_bindgen(getter)]
124    pub fn vector(&self) -> Float32Array {
125        Float32Array::from(&self.inner.vector[..])
126    }
127
128    #[wasm_bindgen(getter)]
129    pub fn metadata(&self) -> Option<JsValue> {
130        self.inner.metadata.as_ref().map(|m| to_value(m).unwrap())
131    }
132}
133
134/// JavaScript-compatible SearchResult
135#[wasm_bindgen]
136pub struct JsSearchResult {
137    inner: SearchResult,
138}
139
140#[wasm_bindgen]
141impl JsSearchResult {
142    #[wasm_bindgen(getter)]
143    pub fn id(&self) -> String {
144        self.inner.id.clone()
145    }
146
147    #[wasm_bindgen(getter)]
148    pub fn score(&self) -> f32 {
149        self.inner.score
150    }
151
152    #[wasm_bindgen(getter)]
153    pub fn vector(&self) -> Option<Float32Array> {
154        self.inner
155            .vector
156            .as_ref()
157            .map(|v| Float32Array::from(&v[..]))
158    }
159
160    #[wasm_bindgen(getter)]
161    pub fn metadata(&self) -> Option<JsValue> {
162        self.inner.metadata.as_ref().map(|m| to_value(m).unwrap())
163    }
164}
165
166/// Main VectorDB class for browser usage
167#[wasm_bindgen]
168pub struct VectorDB {
169    db: Arc<Mutex<CoreVectorDB>>,
170    dimensions: usize,
171    db_name: String,
172}
173
174#[wasm_bindgen]
175impl VectorDB {
176    /// Create a new VectorDB instance
177    ///
178    /// # Arguments
179    /// * `dimensions` - Vector dimensions
180    /// * `metric` - Distance metric ("euclidean", "cosine", "dotproduct", "manhattan")
181    /// * `use_hnsw` - Whether to use HNSW index for faster search
182    #[wasm_bindgen(constructor)]
183    pub fn new(
184        dimensions: usize,
185        metric: Option<String>,
186        use_hnsw: Option<bool>,
187    ) -> Result<VectorDB, JsValue> {
188        let distance_metric = match metric.as_deref() {
189            Some("euclidean") => DistanceMetric::Euclidean,
190            Some("cosine") => DistanceMetric::Cosine,
191            Some("dotproduct") => DistanceMetric::DotProduct,
192            Some("manhattan") => DistanceMetric::Manhattan,
193            None => DistanceMetric::Cosine,
194            Some(other) => return Err(JsValue::from_str(&format!("Unknown metric: {}", other))),
195        };
196
197        let hnsw_config = if use_hnsw.unwrap_or(true) {
198            Some(HnswConfig::default())
199        } else {
200            None
201        };
202
203        let options = DbOptions {
204            dimensions,
205            distance_metric,
206            storage_path: ":memory:".to_string(), // Use in-memory for WASM
207            hnsw_config,
208            quantization: None, // Disable quantization for WASM (for now)
209        };
210
211        let db = CoreVectorDB::new(options).map_err(|e| JsValue::from(WasmError::from(e)))?;
212
213        Ok(VectorDB {
214            db: Arc::new(Mutex::new(db)),
215            dimensions,
216            db_name: format!("ruvector_db_{}", js_sys::Date::now()),
217        })
218    }
219
220    /// Insert a single vector
221    ///
222    /// # Arguments
223    /// * `vector` - Float32Array of vector data
224    /// * `id` - Optional ID (auto-generated if not provided)
225    /// * `metadata` - Optional metadata object
226    ///
227    /// # Returns
228    /// The vector ID
229    #[wasm_bindgen]
230    pub fn insert(
231        &self,
232        vector: Float32Array,
233        id: Option<String>,
234        metadata: Option<JsValue>,
235    ) -> Result<String, JsValue> {
236        let entry = JsVectorEntry::new(vector, id, metadata)?;
237
238        let db = self.db.lock();
239        let vector_id = db
240            .insert(entry.inner)
241            .map_err(|e| JsValue::from(WasmError::from(e)))?;
242
243        Ok(vector_id)
244    }
245
246    /// Insert multiple vectors in a batch (more efficient)
247    ///
248    /// # Arguments
249    /// * `entries` - Array of VectorEntry objects
250    ///
251    /// # Returns
252    /// Array of vector IDs
253    #[wasm_bindgen(js_name = insertBatch)]
254    pub fn insert_batch(&self, entries: JsValue) -> Result<Vec<String>, JsValue> {
255        // Convert JsValue to Array using reflection
256        let entries_array: js_sys::Array = entries
257            .dyn_into()
258            .map_err(|_| JsValue::from_str("entries must be an array"))?;
259
260        let mut vector_entries = Vec::new();
261        for i in 0..entries_array.length() {
262            let js_entry = entries_array.get(i);
263            let vector_arr: Float32Array = Reflect::get(&js_entry, &"vector".into())?.dyn_into()?;
264            let id: Option<String> = Reflect::get(&js_entry, &"id".into())?.as_string();
265            let metadata = Reflect::get(&js_entry, &"metadata".into()).ok();
266
267            let entry = JsVectorEntry::new(vector_arr, id, metadata)?;
268            vector_entries.push(entry.inner);
269        }
270
271        let db = self.db.lock();
272        let ids = db
273            .insert_batch(vector_entries)
274            .map_err(|e| JsValue::from(WasmError::from(e)))?;
275
276        Ok(ids)
277    }
278
279    /// Search for similar vectors
280    ///
281    /// # Arguments
282    /// * `query` - Query vector as Float32Array
283    /// * `k` - Number of results to return
284    /// * `filter` - Optional metadata filter object
285    ///
286    /// # Returns
287    /// Array of search results
288    #[wasm_bindgen]
289    pub fn search(
290        &self,
291        query: Float32Array,
292        k: usize,
293        filter: Option<JsValue>,
294    ) -> Result<Vec<JsSearchResult>, JsValue> {
295        let query_vector: Vec<f32> = query.to_vec();
296
297        if query_vector.len() != self.dimensions {
298            return Err(JsValue::from_str(&format!(
299                "Query vector dimension mismatch: expected {}, got {}",
300                self.dimensions,
301                query_vector.len()
302            )));
303        }
304
305        let metadata_filter = if let Some(f) = filter {
306            Some(from_value(f).map_err(|e| JsValue::from_str(&format!("Invalid filter: {}", e)))?)
307        } else {
308            None
309        };
310
311        let search_query = SearchQuery {
312            vector: query_vector,
313            k,
314            filter: metadata_filter,
315            ef_search: None,
316        };
317
318        let db = self.db.lock();
319        let results = db
320            .search(search_query)
321            .map_err(|e| JsValue::from(WasmError::from(e)))?;
322
323        Ok(results
324            .into_iter()
325            .map(|r| JsSearchResult { inner: r })
326            .collect())
327    }
328
329    /// Delete a vector by ID
330    ///
331    /// # Arguments
332    /// * `id` - Vector ID to delete
333    ///
334    /// # Returns
335    /// True if deleted, false if not found
336    #[wasm_bindgen]
337    pub fn delete(&self, id: &str) -> Result<bool, JsValue> {
338        let db = self.db.lock();
339        db.delete(id).map_err(|e| JsValue::from(WasmError::from(e)))
340    }
341
342    /// Get a vector by ID
343    ///
344    /// # Arguments
345    /// * `id` - Vector ID
346    ///
347    /// # Returns
348    /// VectorEntry or null if not found
349    #[wasm_bindgen]
350    pub fn get(&self, id: &str) -> Result<Option<JsVectorEntry>, JsValue> {
351        let db = self.db.lock();
352        let entry = db.get(id).map_err(|e| JsValue::from(WasmError::from(e)))?;
353
354        Ok(entry.map(|e| JsVectorEntry { inner: e }))
355    }
356
357    /// Get the number of vectors in the database
358    #[wasm_bindgen]
359    pub fn len(&self) -> Result<usize, JsValue> {
360        let db = self.db.lock();
361        db.len().map_err(|e| JsValue::from(WasmError::from(e)))
362    }
363
364    /// Check if the database is empty
365    #[wasm_bindgen(js_name = isEmpty)]
366    pub fn is_empty(&self) -> Result<bool, JsValue> {
367        let db = self.db.lock();
368        db.is_empty().map_err(|e| JsValue::from(WasmError::from(e)))
369    }
370
371    /// Get database dimensions
372    #[wasm_bindgen(getter)]
373    pub fn dimensions(&self) -> usize {
374        self.dimensions
375    }
376
377    /// Save database to IndexedDB
378    /// Returns a Promise that resolves when save is complete
379    #[wasm_bindgen(js_name = saveToIndexedDB)]
380    pub fn save_to_indexed_db(&self) -> Result<Promise, JsValue> {
381        let db_name = self.db_name.clone();
382
383        // For now, log that we would save to IndexedDB
384        // Full implementation would serialize the database state
385        console::log_1(&format!("Saving database '{}' to IndexedDB...", db_name).into());
386
387        // Return resolved promise
388        Ok(Promise::resolve(&JsValue::TRUE))
389    }
390
391    /// Load database from IndexedDB
392    /// Returns a Promise that resolves with the VectorDB instance
393    #[wasm_bindgen(js_name = loadFromIndexedDB)]
394    pub fn load_from_indexed_db(db_name: String) -> Result<Promise, JsValue> {
395        console::log_1(&format!("Loading database '{}' from IndexedDB...", db_name).into());
396
397        // Return rejected promise for now (not implemented)
398        Ok(Promise::reject(&JsValue::from_str("Not yet implemented")))
399    }
400}
401
402/// Detect SIMD support in the current environment
403#[wasm_bindgen(js_name = detectSIMD)]
404pub fn detect_simd() -> bool {
405    // Check for WebAssembly SIMD support
406    #[cfg(target_feature = "simd128")]
407    {
408        true
409    }
410    #[cfg(not(target_feature = "simd128"))]
411    {
412        false
413    }
414}
415
416/// Get version information
417#[wasm_bindgen]
418pub fn version() -> String {
419    env!("CARGO_PKG_VERSION").to_string()
420}
421
422/// Utility: Convert JavaScript array to Float32Array
423#[wasm_bindgen(js_name = arrayToFloat32Array)]
424pub fn array_to_float32_array(arr: Vec<f32>) -> Float32Array {
425    Float32Array::from(&arr[..])
426}
427
428/// Utility: Measure performance of an operation
429#[wasm_bindgen(js_name = benchmark)]
430pub fn benchmark(name: &str, iterations: usize, dimensions: usize) -> Result<f64, JsValue> {
431    use std::time::Instant;
432
433    console::log_1(
434        &format!(
435            "Running benchmark '{}' with {} iterations...",
436            name, iterations
437        )
438        .into(),
439    );
440
441    let db = VectorDB::new(dimensions, Some("cosine".to_string()), Some(false))?;
442
443    let start = Instant::now();
444
445    for i in 0..iterations {
446        let vector: Vec<f32> = (0..dimensions)
447            .map(|_| js_sys::Math::random() as f32)
448            .collect();
449        let vector_arr = Float32Array::from(&vector[..]);
450        db.insert(vector_arr, Some(format!("vec_{}", i)), None)?;
451    }
452
453    let duration = start.elapsed();
454    let ops_per_sec = iterations as f64 / duration.as_secs_f64();
455
456    console::log_1(&format!("Benchmark complete: {:.2} ops/sec", ops_per_sec).into());
457
458    Ok(ops_per_sec)
459}
460
461// ===== Collection Manager =====
462// Note: Collections are not available in standard WASM builds due to file I/O requirements
463// To use collections, compile with the "collections" feature (requires WASI or server environment)
464
465#[cfg(feature = "collections")]
466/// WASM Collection Manager for multi-collection support
467#[wasm_bindgen]
468pub struct CollectionManager {
469    inner: Arc<Mutex<CoreCollectionManager>>,
470}
471
472#[cfg(feature = "collections")]
473#[wasm_bindgen]
474impl CollectionManager {
475    /// Create a new CollectionManager
476    ///
477    /// # Arguments
478    /// * `base_path` - Optional base path for storing collections (defaults to ":memory:")
479    #[wasm_bindgen(constructor)]
480    pub fn new(base_path: Option<String>) -> Result<CollectionManager, JsValue> {
481        let path = base_path.unwrap_or_else(|| ":memory:".to_string());
482
483        let manager = CoreCollectionManager::new(std::path::PathBuf::from(path)).map_err(|e| {
484            JsValue::from_str(&format!("Failed to create collection manager: {}", e))
485        })?;
486
487        Ok(CollectionManager {
488            inner: Arc::new(Mutex::new(manager)),
489        })
490    }
491
492    /// Create a new collection
493    ///
494    /// # Arguments
495    /// * `name` - Collection name (alphanumeric, hyphens, underscores only)
496    /// * `dimensions` - Vector dimensions
497    /// * `metric` - Optional distance metric ("euclidean", "cosine", "dotproduct", "manhattan")
498    #[wasm_bindgen(js_name = createCollection)]
499    pub fn create_collection(
500        &self,
501        name: &str,
502        dimensions: usize,
503        metric: Option<String>,
504    ) -> Result<(), JsValue> {
505        let distance_metric = match metric.as_deref() {
506            Some("euclidean") => DistanceMetric::Euclidean,
507            Some("cosine") => DistanceMetric::Cosine,
508            Some("dotproduct") => DistanceMetric::DotProduct,
509            Some("manhattan") => DistanceMetric::Manhattan,
510            None => DistanceMetric::Cosine,
511            Some(other) => return Err(JsValue::from_str(&format!("Unknown metric: {}", other))),
512        };
513
514        let config = CoreCollectionConfig {
515            dimensions,
516            distance_metric,
517            hnsw_config: Some(HnswConfig::default()),
518            quantization: None,
519            on_disk_payload: false, // Disable for WASM
520        };
521
522        let manager = self.inner.lock();
523        manager
524            .create_collection(name, config)
525            .map_err(|e| JsValue::from_str(&format!("Failed to create collection: {}", e)))?;
526
527        Ok(())
528    }
529
530    /// List all collections
531    ///
532    /// # Returns
533    /// Array of collection names
534    #[wasm_bindgen(js_name = listCollections)]
535    pub fn list_collections(&self) -> Vec<String> {
536        let manager = self.inner.lock();
537        manager.list_collections()
538    }
539
540    /// Delete a collection
541    ///
542    /// # Arguments
543    /// * `name` - Collection name to delete
544    ///
545    /// # Errors
546    /// Returns error if collection has active aliases
547    #[wasm_bindgen(js_name = deleteCollection)]
548    pub fn delete_collection(&self, name: &str) -> Result<(), JsValue> {
549        let manager = self.inner.lock();
550        manager
551            .delete_collection(name)
552            .map_err(|e| JsValue::from_str(&format!("Failed to delete collection: {}", e)))?;
553
554        Ok(())
555    }
556
557    /// Get a collection's VectorDB
558    ///
559    /// # Arguments
560    /// * `name` - Collection name or alias
561    ///
562    /// # Returns
563    /// VectorDB instance or error if not found
564    #[wasm_bindgen(js_name = getCollection)]
565    pub fn get_collection(&self, name: &str) -> Result<VectorDB, JsValue> {
566        let manager = self.inner.lock();
567
568        let collection_ref = manager
569            .get_collection(name)
570            .ok_or_else(|| JsValue::from_str(&format!("Collection '{}' not found", name)))?;
571
572        let collection = collection_ref.read();
573
574        // Create a new VectorDB wrapper that shares the underlying database
575        // Note: For WASM, we'll need to clone the DB state since we can't share references across WASM boundary
576        // This is a simplified version - in production you might want a different approach
577        let dimensions = collection.config.dimensions;
578        let db_name = collection.name.clone();
579
580        // For now, return a new VectorDB with the same config
581        // In a real implementation, you'd want to share the underlying storage
582        let db_options = DbOptions {
583            dimensions: collection.config.dimensions,
584            distance_metric: collection.config.distance_metric,
585            storage_path: ":memory:".to_string(),
586            hnsw_config: collection.config.hnsw_config.clone(),
587            quantization: collection.config.quantization.clone(),
588        };
589
590        let db = CoreVectorDB::new(db_options)
591            .map_err(|e| JsValue::from_str(&format!("Failed to get collection: {}", e)))?;
592
593        Ok(VectorDB {
594            db: Arc::new(Mutex::new(db)),
595            dimensions,
596            db_name,
597        })
598    }
599
600    /// Create an alias
601    ///
602    /// # Arguments
603    /// * `alias` - Alias name (must be unique)
604    /// * `collection` - Target collection name
605    #[wasm_bindgen(js_name = createAlias)]
606    pub fn create_alias(&self, alias: &str, collection: &str) -> Result<(), JsValue> {
607        let manager = self.inner.lock();
608        manager
609            .create_alias(alias, collection)
610            .map_err(|e| JsValue::from_str(&format!("Failed to create alias: {}", e)))?;
611
612        Ok(())
613    }
614
615    /// Delete an alias
616    ///
617    /// # Arguments
618    /// * `alias` - Alias name to delete
619    #[wasm_bindgen(js_name = deleteAlias)]
620    pub fn delete_alias(&self, alias: &str) -> Result<(), JsValue> {
621        let manager = self.inner.lock();
622        manager
623            .delete_alias(alias)
624            .map_err(|e| JsValue::from_str(&format!("Failed to delete alias: {}", e)))?;
625
626        Ok(())
627    }
628
629    /// List all aliases
630    ///
631    /// # Returns
632    /// JavaScript array of [alias, collection] pairs
633    #[wasm_bindgen(js_name = listAliases)]
634    pub fn list_aliases(&self) -> JsValue {
635        let manager = self.inner.lock();
636        let aliases = manager.list_aliases();
637
638        let arr = Array::new();
639        for (alias, collection) in aliases {
640            let pair = Array::new();
641            pair.push(&JsValue::from_str(&alias));
642            pair.push(&JsValue::from_str(&collection));
643            arr.push(&pair);
644        }
645
646        arr.into()
647    }
648}
649
650// ===== Filter Builder =====
651
652#[cfg(feature = "collections")]
653/// JavaScript-compatible filter builder
654#[wasm_bindgen]
655pub struct FilterBuilder {
656    inner: CoreFilterExpression,
657}
658
659#[cfg(feature = "collections")]
660#[wasm_bindgen]
661impl FilterBuilder {
662    /// Create a new empty filter builder
663    #[wasm_bindgen(constructor)]
664    pub fn new() -> FilterBuilder {
665        // Default to a match-all filter (we'll use exists on a common field)
666        // Users should use the builder methods instead
667        FilterBuilder {
668            inner: CoreFilterExpression::exists("_id"),
669        }
670    }
671
672    /// Create an equality filter
673    ///
674    /// # Arguments
675    /// * `field` - Field name
676    /// * `value` - Value to match (will be converted from JS)
677    ///
678    /// # Example
679    /// ```javascript
680    /// const filter = FilterBuilder.eq("status", "active");
681    /// ```
682    pub fn eq(field: &str, value: JsValue) -> Result<FilterBuilder, JsValue> {
683        let json_value: serde_json::Value =
684            from_value(value).map_err(|e| JsValue::from_str(&format!("Invalid value: {}", e)))?;
685
686        Ok(FilterBuilder {
687            inner: CoreFilterExpression::eq(field, json_value),
688        })
689    }
690
691    /// Create a not-equal filter
692    pub fn ne(field: &str, value: JsValue) -> Result<FilterBuilder, JsValue> {
693        let json_value: serde_json::Value =
694            from_value(value).map_err(|e| JsValue::from_str(&format!("Invalid value: {}", e)))?;
695
696        Ok(FilterBuilder {
697            inner: CoreFilterExpression::ne(field, json_value),
698        })
699    }
700
701    /// Create a greater-than filter
702    pub fn gt(field: &str, value: JsValue) -> Result<FilterBuilder, JsValue> {
703        let json_value: serde_json::Value =
704            from_value(value).map_err(|e| JsValue::from_str(&format!("Invalid value: {}", e)))?;
705
706        Ok(FilterBuilder {
707            inner: CoreFilterExpression::gt(field, json_value),
708        })
709    }
710
711    /// Create a greater-than-or-equal filter
712    pub fn gte(field: &str, value: JsValue) -> Result<FilterBuilder, JsValue> {
713        let json_value: serde_json::Value =
714            from_value(value).map_err(|e| JsValue::from_str(&format!("Invalid value: {}", e)))?;
715
716        Ok(FilterBuilder {
717            inner: CoreFilterExpression::gte(field, json_value),
718        })
719    }
720
721    /// Create a less-than filter
722    pub fn lt(field: &str, value: JsValue) -> Result<FilterBuilder, JsValue> {
723        let json_value: serde_json::Value =
724            from_value(value).map_err(|e| JsValue::from_str(&format!("Invalid value: {}", e)))?;
725
726        Ok(FilterBuilder {
727            inner: CoreFilterExpression::lt(field, json_value),
728        })
729    }
730
731    /// Create a less-than-or-equal filter
732    pub fn lte(field: &str, value: JsValue) -> Result<FilterBuilder, JsValue> {
733        let json_value: serde_json::Value =
734            from_value(value).map_err(|e| JsValue::from_str(&format!("Invalid value: {}", e)))?;
735
736        Ok(FilterBuilder {
737            inner: CoreFilterExpression::lte(field, json_value),
738        })
739    }
740
741    /// Create an IN filter (field matches any of the values)
742    ///
743    /// # Arguments
744    /// * `field` - Field name
745    /// * `values` - Array of values
746    #[wasm_bindgen(js_name = "in")]
747    pub fn in_values(field: &str, values: JsValue) -> Result<FilterBuilder, JsValue> {
748        let json_values: Vec<serde_json::Value> = from_value(values)
749            .map_err(|e| JsValue::from_str(&format!("Invalid values array: {}", e)))?;
750
751        Ok(FilterBuilder {
752            inner: CoreFilterExpression::in_values(field, json_values),
753        })
754    }
755
756    /// Create a text match filter
757    ///
758    /// # Arguments
759    /// * `field` - Field name
760    /// * `text` - Text to search for
761    #[wasm_bindgen(js_name = matchText)]
762    pub fn match_text(field: &str, text: &str) -> FilterBuilder {
763        FilterBuilder {
764            inner: CoreFilterExpression::match_text(field, text),
765        }
766    }
767
768    /// Create a geo radius filter
769    ///
770    /// # Arguments
771    /// * `field` - Field name (should contain {lat, lon} object)
772    /// * `lat` - Center latitude
773    /// * `lon` - Center longitude
774    /// * `radius_m` - Radius in meters
775    #[wasm_bindgen(js_name = geoRadius)]
776    pub fn geo_radius(field: &str, lat: f64, lon: f64, radius_m: f64) -> FilterBuilder {
777        FilterBuilder {
778            inner: CoreFilterExpression::geo_radius(field, lat, lon, radius_m),
779        }
780    }
781
782    /// Combine filters with AND
783    ///
784    /// # Arguments
785    /// * `filters` - Array of FilterBuilder instances
786    pub fn and(filters: Vec<FilterBuilder>) -> FilterBuilder {
787        let inner_filters: Vec<CoreFilterExpression> =
788            filters.into_iter().map(|f| f.inner).collect();
789
790        FilterBuilder {
791            inner: CoreFilterExpression::and(inner_filters),
792        }
793    }
794
795    /// Combine filters with OR
796    ///
797    /// # Arguments
798    /// * `filters` - Array of FilterBuilder instances
799    pub fn or(filters: Vec<FilterBuilder>) -> FilterBuilder {
800        let inner_filters: Vec<CoreFilterExpression> =
801            filters.into_iter().map(|f| f.inner).collect();
802
803        FilterBuilder {
804            inner: CoreFilterExpression::or(inner_filters),
805        }
806    }
807
808    /// Negate a filter with NOT
809    ///
810    /// # Arguments
811    /// * `filter` - FilterBuilder instance to negate
812    pub fn not(filter: FilterBuilder) -> FilterBuilder {
813        FilterBuilder {
814            inner: CoreFilterExpression::not(filter.inner),
815        }
816    }
817
818    /// Create an EXISTS filter (field is present)
819    pub fn exists(field: &str) -> FilterBuilder {
820        FilterBuilder {
821            inner: CoreFilterExpression::exists(field),
822        }
823    }
824
825    /// Create an IS NULL filter (field is null)
826    #[wasm_bindgen(js_name = isNull)]
827    pub fn is_null(field: &str) -> FilterBuilder {
828        FilterBuilder {
829            inner: CoreFilterExpression::is_null(field),
830        }
831    }
832
833    /// Convert to JSON for use with search
834    ///
835    /// # Returns
836    /// JavaScript object representing the filter
837    #[wasm_bindgen(js_name = toJson)]
838    pub fn to_json(&self) -> Result<JsValue, JsValue> {
839        to_value(&self.inner)
840            .map_err(|e| JsValue::from_str(&format!("Failed to serialize filter: {}", e)))
841    }
842
843    /// Get all field names referenced in this filter
844    #[wasm_bindgen(js_name = getFields)]
845    pub fn get_fields(&self) -> Vec<String> {
846        self.inner.get_fields()
847    }
848}
849
850#[cfg(feature = "collections")]
851impl Default for FilterBuilder {
852    fn default() -> Self {
853        Self::new()
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use wasm_bindgen_test::*;
861
862    wasm_bindgen_test_configure!(run_in_browser);
863
864    #[wasm_bindgen_test]
865    fn test_version() {
866        assert!(!version().is_empty());
867    }
868
869    #[wasm_bindgen_test]
870    fn test_detect_simd() {
871        // Just ensure it doesn't panic
872        let _ = detect_simd();
873    }
874}