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}