edgevec/wasm/
metadata.rs

1//! WASM Bindings for EdgeVec Metadata API.
2//!
3//! This module provides JavaScript-friendly wrappers for the metadata system,
4//! allowing browser applications to attach, query, and manage metadata on vectors.
5//!
6//! # JavaScript Usage
7//!
8//! ```javascript
9//! import { EdgeVec, JsMetadataValue } from 'edgevec';
10//!
11//! const db = new EdgeVec(config);
12//! const id = db.insert(vector);
13//!
14//! // Attach metadata
15//! db.setMetadata(id, 'title', JsMetadataValue.fromString('My Document'));
16//! db.setMetadata(id, 'page_count', JsMetadataValue.fromInteger(42));
17//!
18//! // Retrieve metadata
19//! const title = db.getMetadata(id, 'title');
20//! console.log(title.asString()); // 'My Document'
21//!
22//! // Get all metadata as JS object
23//! const all = db.getAllMetadata(id);
24//! console.log(all); // { title: 'My Document', page_count: 42 }
25//! ```
26
27use crate::metadata::{MetadataError, MetadataStore, MetadataValue};
28use js_sys::Array;
29use wasm_bindgen::prelude::*;
30
31// =============================================================================
32// JavaScript Safe Integer Constants
33// =============================================================================
34
35/// Maximum safe integer in JavaScript (2^53 - 1).
36/// Values larger than this may lose precision when stored in f64.
37const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
38
39/// Minimum safe integer in JavaScript (-(2^53 - 1)).
40const MIN_SAFE_INTEGER: f64 = -9_007_199_254_740_991.0;
41
42// =============================================================================
43// JsMetadataValue - JavaScript-friendly wrapper for MetadataValue
44// =============================================================================
45
46/// JavaScript-friendly metadata value representation.
47///
48/// This type bridges Rust's `MetadataValue` enum to JavaScript objects.
49/// Use the static factory methods (`fromString`, `fromInteger`, etc.) to create
50/// values from JavaScript.
51///
52/// # Example (JavaScript)
53///
54/// ```javascript
55/// const strValue = JsMetadataValue.fromString('hello');
56/// const intValue = JsMetadataValue.fromInteger(42);
57/// const floatValue = JsMetadataValue.fromFloat(3.14);
58/// const boolValue = JsMetadataValue.fromBoolean(true);
59/// const arrValue = JsMetadataValue.fromStringArray(['a', 'b', 'c']);
60///
61/// console.log(strValue.getType()); // 'string'
62/// console.log(intValue.toJS());    // 42
63/// ```
64#[wasm_bindgen]
65pub struct JsMetadataValue {
66    pub(crate) inner: MetadataValue,
67}
68
69#[wasm_bindgen]
70impl JsMetadataValue {
71    // =========================================================================
72    // Factory Methods (Static Constructors)
73    // =========================================================================
74
75    /// Creates a string metadata value.
76    ///
77    /// @param value - The string value
78    /// @returns A new JsMetadataValue containing a string
79    #[wasm_bindgen(js_name = "fromString")]
80    #[must_use]
81    pub fn from_string(value: String) -> Self {
82        Self {
83            inner: MetadataValue::String(value),
84        }
85    }
86
87    /// Creates an integer metadata value.
88    ///
89    /// JavaScript numbers are always f64, so this method validates the input
90    /// to ensure it's a valid integer within JavaScript's safe integer range.
91    ///
92    /// @param value - The integer value (must be within ±(2^53 - 1))
93    /// @returns A new JsMetadataValue containing an integer
94    /// @throws {Error} If value is outside safe integer range or has fractional part
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if:
99    /// - Value exceeds JavaScript's safe integer range (±9007199254740991)
100    /// - Value has a fractional part (e.g., 3.14)
101    /// - Value is NaN or Infinity
102    #[wasm_bindgen(js_name = "fromInteger")]
103    #[allow(clippy::cast_possible_truncation)]
104    pub fn from_integer(value: f64) -> Result<Self, JsError> {
105        // Check for NaN or Infinity
106        if !value.is_finite() {
107            return Err(JsError::new(
108                "Integer value must be finite (not NaN or Infinity)",
109            ));
110        }
111
112        // Check for fractional part
113        if value.fract() != 0.0 {
114            return Err(JsError::new(&format!(
115                "Value {value} is not an integer (has fractional part)"
116            )));
117        }
118
119        // Check safe integer range
120        if !(MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&value) {
121            return Err(JsError::new(&format!(
122                "Integer value {value} exceeds JavaScript safe integer range (±{MAX_SAFE_INTEGER})"
123            )));
124        }
125
126        // JavaScript doesn't have a native i64 type, so we receive f64
127        // and convert to i64. This is safe within the validated range.
128        Ok(Self {
129            inner: MetadataValue::Integer(value as i64),
130        })
131    }
132
133    /// Creates a float metadata value.
134    ///
135    /// @param value - The float value (must not be NaN or Infinity)
136    /// @returns A new JsMetadataValue containing a float
137    #[wasm_bindgen(js_name = "fromFloat")]
138    #[must_use]
139    pub fn from_float(value: f64) -> Self {
140        Self {
141            inner: MetadataValue::Float(value),
142        }
143    }
144
145    /// Creates a boolean metadata value.
146    ///
147    /// @param value - The boolean value
148    /// @returns A new JsMetadataValue containing a boolean
149    #[wasm_bindgen(js_name = "fromBoolean")]
150    #[must_use]
151    pub fn from_boolean(value: bool) -> Self {
152        Self {
153            inner: MetadataValue::Boolean(value),
154        }
155    }
156
157    /// Creates a string array metadata value.
158    ///
159    /// @param value - An array of strings
160    /// @returns A new JsMetadataValue containing a string array
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if any array element is not a string.
165    #[wasm_bindgen(js_name = "fromStringArray")]
166    #[allow(clippy::needless_pass_by_value)]
167    pub fn from_string_array(value: Array) -> Result<Self, JsError> {
168        let mut strings = Vec::with_capacity(value.length() as usize);
169
170        for i in 0..value.length() {
171            let item = value.get(i);
172            let s = item
173                .as_string()
174                .ok_or_else(|| JsError::new("Array elements must be strings"))?;
175            strings.push(s);
176        }
177
178        Ok(Self {
179            inner: MetadataValue::StringArray(strings),
180        })
181    }
182
183    // =========================================================================
184    // Type Inspection
185    // =========================================================================
186
187    /// Returns the type of this value.
188    ///
189    /// @returns One of: 'string', 'integer', 'float', 'boolean', 'string_array'
190    #[wasm_bindgen(js_name = "getType")]
191    #[must_use]
192    pub fn get_type(&self) -> String {
193        self.inner.type_name().to_string()
194    }
195
196    /// Checks if this value is a string.
197    #[wasm_bindgen(js_name = "isString")]
198    #[must_use]
199    pub fn is_string(&self) -> bool {
200        self.inner.is_string()
201    }
202
203    /// Checks if this value is an integer.
204    #[wasm_bindgen(js_name = "isInteger")]
205    #[must_use]
206    pub fn is_integer(&self) -> bool {
207        self.inner.is_integer()
208    }
209
210    /// Checks if this value is a float.
211    #[wasm_bindgen(js_name = "isFloat")]
212    #[must_use]
213    pub fn is_float(&self) -> bool {
214        self.inner.is_float()
215    }
216
217    /// Checks if this value is a boolean.
218    #[wasm_bindgen(js_name = "isBoolean")]
219    #[must_use]
220    pub fn is_boolean(&self) -> bool {
221        self.inner.is_boolean()
222    }
223
224    /// Checks if this value is a string array.
225    #[wasm_bindgen(js_name = "isStringArray")]
226    #[must_use]
227    pub fn is_string_array(&self) -> bool {
228        self.inner.is_string_array()
229    }
230
231    // =========================================================================
232    // Value Extraction
233    // =========================================================================
234
235    /// Gets the value as a string.
236    ///
237    /// @returns The string value, or undefined if not a string
238    #[wasm_bindgen(js_name = "asString")]
239    #[must_use]
240    pub fn as_string(&self) -> Option<String> {
241        self.inner.as_string().map(String::from)
242    }
243
244    /// Gets the value as an integer.
245    ///
246    /// Note: Returns as f64 for JavaScript compatibility. Safe for integers up to ±2^53.
247    ///
248    /// @returns The integer value as a number, or undefined if not an integer
249    #[wasm_bindgen(js_name = "asInteger")]
250    #[must_use]
251    #[allow(clippy::cast_precision_loss)]
252    pub fn as_integer(&self) -> Option<f64> {
253        // Return as f64 for JavaScript compatibility
254        self.inner.as_integer().map(|i| i as f64)
255    }
256
257    /// Gets the value as a float.
258    ///
259    /// @returns The float value, or undefined if not a float
260    #[wasm_bindgen(js_name = "asFloat")]
261    #[must_use]
262    pub fn as_float(&self) -> Option<f64> {
263        self.inner.as_float()
264    }
265
266    /// Gets the value as a boolean.
267    ///
268    /// @returns The boolean value, or undefined if not a boolean
269    #[wasm_bindgen(js_name = "asBoolean")]
270    #[must_use]
271    pub fn as_boolean(&self) -> Option<bool> {
272        self.inner.as_boolean()
273    }
274
275    /// Gets the value as a string array.
276    ///
277    /// @returns The string array, or undefined if not a string array
278    #[wasm_bindgen(js_name = "asStringArray")]
279    #[must_use]
280    pub fn as_string_array(&self) -> JsValue {
281        match self.inner.as_string_array() {
282            Some(arr) => {
283                let js_array = Array::new();
284                for s in arr {
285                    js_array.push(&JsValue::from_str(s));
286                }
287                js_array.into()
288            }
289            None => JsValue::UNDEFINED,
290        }
291    }
292
293    // =========================================================================
294    // JavaScript Conversion
295    // =========================================================================
296
297    /// Converts to a JavaScript-native value.
298    ///
299    /// Returns:
300    /// - `string` for String values
301    /// - `number` for Integer and Float values
302    /// - `boolean` for Boolean values
303    /// - `string[]` for StringArray values
304    ///
305    /// @returns The JavaScript-native value
306    #[wasm_bindgen(js_name = "toJS")]
307    #[must_use]
308    pub fn to_js(&self) -> JsValue {
309        match &self.inner {
310            MetadataValue::String(s) => JsValue::from_str(s),
311            MetadataValue::Integer(i) => {
312                // Safe cast for values up to ±2^53
313                #[allow(clippy::cast_precision_loss)]
314                JsValue::from_f64(*i as f64)
315            }
316            MetadataValue::Float(f) => JsValue::from_f64(*f),
317            MetadataValue::Boolean(b) => JsValue::from_bool(*b),
318            MetadataValue::StringArray(arr) => {
319                let js_array = Array::new();
320                for s in arr {
321                    js_array.push(&JsValue::from_str(s));
322                }
323                js_array.into()
324            }
325        }
326    }
327}
328
329// =============================================================================
330// Helper Functions for EdgeVec Integration
331// =============================================================================
332
333/// Internal helper to convert MetadataError to JsError.
334///
335/// Takes ownership of the error since it's typically used in `.map_err()`
336/// where the error is consumed anyway.
337#[allow(clippy::needless_pass_by_value)]
338pub(crate) fn metadata_error_to_js(e: MetadataError) -> JsError {
339    JsError::new(&e.to_string())
340}
341
342/// Internal helper to convert Option<&MetadataValue> to Option<JsMetadataValue>.
343pub(crate) fn metadata_value_to_js(value: Option<&MetadataValue>) -> Option<JsMetadataValue> {
344    value.map(|v| JsMetadataValue { inner: v.clone() })
345}
346
347/// Internal helper to convert all metadata for a vector to a JS object.
348pub(crate) fn metadata_to_js_object(store: &MetadataStore, vector_id: u32) -> JsValue {
349    match store.get_all(vector_id) {
350        Some(metadata) => {
351            let obj = js_sys::Object::new();
352            for (key, value) in metadata {
353                let js_value = JsMetadataValue {
354                    inner: value.clone(),
355                };
356                // Silently ignore errors (shouldn't happen for valid keys)
357                let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(key), &js_value.to_js());
358            }
359            obj.into()
360        }
361        None => JsValue::UNDEFINED,
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_js_metadata_value_string() {
371        let value = JsMetadataValue::from_string("hello".to_string());
372        assert!(value.is_string());
373        assert_eq!(value.get_type(), "string");
374        assert_eq!(value.as_string(), Some("hello".to_string()));
375    }
376
377    #[test]
378    fn test_js_metadata_value_integer() {
379        let value = JsMetadataValue::from_integer(42.0).unwrap();
380        assert!(value.is_integer());
381        assert_eq!(value.get_type(), "integer");
382        assert_eq!(value.as_integer(), Some(42.0));
383    }
384
385    #[test]
386    fn test_from_integer_valid_range() {
387        // Valid integers should work (these don't call JsError::new)
388        assert!(JsMetadataValue::from_integer(0.0).is_ok());
389        assert!(JsMetadataValue::from_integer(-1.0).is_ok());
390        assert!(JsMetadataValue::from_integer(1_000_000.0).is_ok());
391
392        // MAX_SAFE_INTEGER should work
393        let max_safe = 9_007_199_254_740_991.0;
394        assert!(JsMetadataValue::from_integer(max_safe).is_ok());
395        assert!(JsMetadataValue::from_integer(-max_safe).is_ok());
396    }
397
398    // Note: Tests for invalid integers (fractional, NaN, Infinity, out of range)
399    // cannot be run on non-wasm targets because JsError::new is wasm-only.
400    // These validations are tested via wasm-pack integration tests.
401
402    #[test]
403    fn test_js_metadata_value_float() {
404        let value = JsMetadataValue::from_float(3.125);
405        assert!(value.is_float());
406        assert_eq!(value.get_type(), "float");
407        assert_eq!(value.as_float(), Some(3.125));
408    }
409
410    #[test]
411    fn test_js_metadata_value_boolean() {
412        let value = JsMetadataValue::from_boolean(true);
413        assert!(value.is_boolean());
414        assert_eq!(value.get_type(), "boolean");
415        assert_eq!(value.as_boolean(), Some(true));
416    }
417
418    // Note: test_metadata_error_to_js is not included because JsError::new
419    // can only be called on wasm targets. The function is tested via
420    // integration tests in wasm-pack test.
421}