Skip to main content

velesdb_mobile/
lib.rs

1// Mobile SDK - pedantic/nursery lints relaxed for UniFFI FFI boundary
2#![allow(clippy::pedantic)]
3#![allow(clippy::nursery)]
4#![allow(clippy::needless_pass_by_value)]
5// FFI boundary - pedantic lints relaxed for UniFFI compatibility
6#![allow(clippy::missing_errors_doc)]
7#![allow(clippy::missing_panics_doc)]
8#![allow(clippy::must_use_candidate)]
9#![allow(clippy::uninlined_format_args)]
10#![allow(clippy::similar_names)]
11#![allow(clippy::module_name_repetitions)]
12#![allow(clippy::doc_markdown)]
13#![allow(clippy::wildcard_imports)]
14#![allow(clippy::redundant_closure_for_method_calls)]
15
16//! VelesDB Mobile - Native bindings for iOS and Android
17//!
18//! This crate provides UniFFI bindings for VelesDB, enabling native integration
19//! with Swift (iOS) and Kotlin (Android) applications.
20//!
21//! # Architecture
22//!
23//! - **iOS**: Generates Swift bindings + XCFramework (arm64 device, arm64/x86_64 simulator)
24//! - **Android**: Generates Kotlin bindings + AAR (arm64-v8a, armeabi-v7a, x86_64)
25//!
26//! # Build Commands
27//!
28//! ```bash
29//! # iOS - build for device and simulator
30//! cargo build --release --target aarch64-apple-ios
31//! cargo build --release --target aarch64-apple-ios-sim
32//! cargo build --release --target x86_64-apple-ios  # Intel simulator
33//!
34//! # iOS - create universal binary + XCFramework
35//! lipo -create \
36//!   target/aarch64-apple-ios-sim/release/libvelesdb_mobile.a \
37//!   target/x86_64-apple-ios/release/libvelesdb_mobile.a \
38//!   -output target/universal-sim/libvelesdb_mobile.a
39//! xcodebuild -create-xcframework \
40//!   -library target/aarch64-apple-ios/release/libvelesdb_mobile.a \
41//!   -library target/universal-sim/libvelesdb_mobile.a \
42//!   -output VelesDB.xcframework
43//!
44//! # Android (requires cargo-ndk: cargo install cargo-ndk)
45//! cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 build --release
46//! ```
47
48uniffi::setup_scaffolding!();
49
50mod agent;
51mod collection;
52mod collection_sparse;
53mod graph;
54mod observer;
55mod query;
56mod streaming_runtime;
57mod types;
58
59pub use agent::{SemanticResult, VelesSemanticMemory};
60pub use collection::VelesCollection;
61pub use graph::{MobileGraphEdge, MobileGraphNode, MobileGraphStore, TraversalResult};
62pub use observer::{
63    MobileAccessDecision, MobileObserver, MobileQueryContext, MobileQueryOperationKind,
64};
65pub use query::{QueryResult, QueryResultKind, QueryResultRow};
66pub use types::{
67    DistanceMetric, FusionStrategy, IndividualSearchRequest, MobileAdvancedConfig,
68    MobileAsyncIndexBuilderConfig, MobileCollectionDiagnostics, MobileCollectionStats,
69    MobileDeferredIndexerConfig, MobileIndexInfo, MobileQueryLimits, MobileStreamingConfig,
70    PqTrainConfig, SearchQuality, SearchResult, StorageMode, VelesError, VelesPoint,
71    VelesSparseVector,
72};
73
74use std::sync::Arc;
75use velesdb_core::{Database as CoreDatabase, DatabaseObserver};
76
77use crate::observer::ForeignObserver;
78
79#[cfg(test)]
80use velesdb_core::DistanceMetric as CoreDistanceMetric;
81#[cfg(test)]
82use velesdb_core::FusionStrategy as CoreFusionStrategy;
83#[cfg(test)]
84use velesdb_core::SearchQuality as CoreSearchQuality;
85
86// NOTE: VelesError, DistanceMetric, StorageMode, FusionStrategy, SearchResult,
87// VelesPoint, IndividualSearchRequest moved to types.rs (EPIC-061/US-005 refactoring)
88// NOTE: VelesCollection moved to collection.rs (NLOC/CC resolution)
89
90// ============================================================================
91// Engine config loading (issue #1549, mobile surface)
92// ============================================================================
93
94/// Maps a [`velesdb_core::config::ConfigError`] to the FFI [`VelesError`].
95///
96/// UniFFI errors are flat records (no `#[source]` chain survives the FFI
97/// boundary), so the `ConfigError` is preserved the closest way the surface
98/// allows: routed through [`velesdb_core::Error::Config`] so the mobile error
99/// carries the canonical `VELES-009` taxonomy code, core's recoverability
100/// flag, and the full underlying `ConfigError` message.
101fn config_error(err: velesdb_core::config::ConfigError) -> VelesError {
102    velesdb_core::Error::Config(err.to_string()).into()
103}
104
105/// Loads a [`velesdb_core::config::VelesConfig`] from a TOML file, engine
106/// sections only. Fail-fast: a missing/unreadable/invalid file is an
107/// immediate typed error, never a silent fallback to defaults.
108fn load_engine_config_from_path(
109    config_path: &str,
110) -> Result<velesdb_core::config::VelesConfig, VelesError> {
111    velesdb_core::config::VelesConfig::load_from_path_engine_only(config_path).map_err(config_error)
112}
113
114/// Parses a [`velesdb_core::config::VelesConfig`] from an in-memory TOML
115/// string, engine sections only. Same fail-fast semantics as
116/// [`load_engine_config_from_path`].
117fn load_engine_config_from_toml(
118    config_toml: &str,
119) -> Result<velesdb_core::config::VelesConfig, VelesError> {
120    velesdb_core::config::VelesConfig::from_toml_engine_only(config_toml).map_err(config_error)
121}
122
123// ============================================================================
124// Database
125// ============================================================================
126
127/// VelesDB database instance.
128///
129/// Thread-safe handle to a VelesDB database. Can be shared across threads.
130#[derive(uniffi::Object)]
131pub struct VelesDatabase {
132    /// Shared handle to the core database. Held behind an `Arc` so each
133    /// [`VelesCollection`] minted from it can carry a clone and route its reads
134    /// back through this database's control-plane gate (`gated_search` /
135    /// `authorize_read`) rather than hitting its detached collection leaf
136    /// directly — the read gate that observer governance depends on
137    /// (audit F-5.4, #1392).
138    inner: Arc<CoreDatabase>,
139}
140
141#[uniffi::export]
142impl VelesDatabase {
143    /// Opens or creates a database at the specified path.
144    ///
145    /// # Arguments
146    ///
147    /// * `path` - Path to the database directory (will be created if needed)
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the path is invalid or cannot be accessed.
152    #[uniffi::constructor]
153    pub fn open(path: String) -> Result<Arc<Self>, VelesError> {
154        let db = CoreDatabase::open(&path)?;
155        Ok(Arc::new(Self {
156            inner: Arc::new(db),
157        }))
158    }
159
160    /// Opens or creates a database with a read-path [`MobileObserver`] attached.
161    ///
162    /// The observer is consulted before every governed read (dense / text /
163    /// hybrid / sparse / multi-query search and `VelesQL` `SELECT` / `MATCH`):
164    /// returning [`MobileAccessDecision::Deny`] aborts the read with that
165    /// message and zero results, [`MobileAccessDecision::Allow`] runs it
166    /// unmodified. This is the mobile counterpart of the observer gate already
167    /// wired on server and Python (audit F-5.4, #1392).
168    ///
169    /// # Arguments
170    ///
171    /// * `path` - Path to the database directory (will be created if needed)
172    /// * `observer` - A Kotlin/Swift implementation of [`MobileObserver`]
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the path is invalid or cannot be accessed.
177    #[uniffi::constructor]
178    pub fn open_with_observer(
179        path: String,
180        observer: Arc<dyn MobileObserver>,
181    ) -> Result<Arc<Self>, VelesError> {
182        let core_observer: Arc<dyn DatabaseObserver> = Arc::new(ForeignObserver::new(observer));
183        let db = CoreDatabase::open_with_observer(&path, core_observer)?;
184        Ok(Arc::new(Self {
185            inner: Arc::new(db),
186        }))
187    }
188
189    /// Opens or creates a database configured from a TOML file on disk.
190    ///
191    /// The file is parsed with
192    /// [`VelesConfig::load_from_path_engine_only`](velesdb_core::config::VelesConfig::load_from_path_engine_only):
193    /// only the engine sections (`[search]`/`[hnsw]`/`[storage]`/`[limits]`/
194    /// `[quantization]`/`[wal_batch]`) are considered, any other top-level
195    /// table is dropped, and `VELESDB_*` environment variables still layer on
196    /// top of the filtered file. The database is then opened with
197    /// [`Database::open_with_config`](velesdb_core::Database::open_with_config),
198    /// so every subsystem honours the loaded values instead of core defaults.
199    ///
200    /// This is the mobile counterpart of the server/CLI `--config` wiring
201    /// (issue #1549). If the app ships its config as an in-memory string
202    /// (bundled asset, remote config), use
203    /// [`open_with_config_toml`](Self::open_with_config_toml) instead.
204    ///
205    /// # Arguments
206    ///
207    /// * `path` - Path to the database directory (will be created if needed)
208    /// * `config_path` - Path to an existing TOML configuration file
209    ///
210    /// # Errors
211    ///
212    /// Fails fast — never falls back to defaults silently — if the config
213    /// file is missing, unreadable, not valid TOML, or fails validation
214    /// (typed as a `VELES-009` configuration error), or if the database path
215    /// is invalid or cannot be accessed.
216    #[uniffi::constructor]
217    pub fn open_with_config(path: String, config_path: String) -> Result<Arc<Self>, VelesError> {
218        let config = load_engine_config_from_path(&config_path)?;
219        let db = CoreDatabase::open_with_config(&path, config)?;
220        Ok(Arc::new(Self {
221            inner: Arc::new(db),
222        }))
223    }
224
225    /// Opens or creates a database configured from an in-memory TOML string.
226    ///
227    /// Same semantics as [`open_with_config`](Self::open_with_config) but the
228    /// TOML is passed directly (parsed with
229    /// [`VelesConfig::from_toml_engine_only`](velesdb_core::config::VelesConfig::from_toml_engine_only),
230    /// no environment-variable layer) — the most portable option on mobile,
231    /// where config often lives in a bundled asset or remote-config payload
232    /// rather than a standalone file.
233    ///
234    /// # Arguments
235    ///
236    /// * `path` - Path to the database directory (will be created if needed)
237    /// * `config_toml` - TOML configuration string (engine sections only)
238    ///
239    /// # Errors
240    ///
241    /// Fails fast — never falls back to defaults silently — if `config_toml`
242    /// is not valid TOML or fails validation (typed as a `VELES-009`
243    /// configuration error), or if the database path is invalid or cannot be
244    /// accessed.
245    #[uniffi::constructor]
246    pub fn open_with_config_toml(
247        path: String,
248        config_toml: String,
249    ) -> Result<Arc<Self>, VelesError> {
250        let config = load_engine_config_from_toml(&config_toml)?;
251        let db = CoreDatabase::open_with_config(&path, config)?;
252        Ok(Arc::new(Self {
253            inner: Arc::new(db),
254        }))
255    }
256
257    /// Opens or creates a database with both a read-path [`MobileObserver`]
258    /// and a TOML configuration file.
259    ///
260    /// Combines [`open_with_observer`](Self::open_with_observer) (read gate)
261    /// and [`open_with_config`](Self::open_with_config) (engine config) on a
262    /// single handle via
263    /// [`Database::open_with_observer_and_config`](velesdb_core::Database::open_with_observer_and_config).
264    ///
265    /// # Arguments
266    ///
267    /// * `path` - Path to the database directory (will be created if needed)
268    /// * `observer` - A Kotlin/Swift implementation of [`MobileObserver`]
269    /// * `config_path` - Path to an existing TOML configuration file
270    ///
271    /// # Errors
272    ///
273    /// Same fail-fast semantics as [`open_with_config`](Self::open_with_config).
274    #[uniffi::constructor]
275    pub fn open_with_observer_and_config(
276        path: String,
277        observer: Arc<dyn MobileObserver>,
278        config_path: String,
279    ) -> Result<Arc<Self>, VelesError> {
280        let config = load_engine_config_from_path(&config_path)?;
281        let core_observer: Arc<dyn DatabaseObserver> = Arc::new(ForeignObserver::new(observer));
282        let db = CoreDatabase::open_with_observer_and_config(&path, core_observer, config)?;
283        Ok(Arc::new(Self {
284            inner: Arc::new(db),
285        }))
286    }
287
288    /// Opens or creates a database with both a read-path [`MobileObserver`]
289    /// and an in-memory TOML configuration string.
290    ///
291    /// Combines [`open_with_observer`](Self::open_with_observer) (read gate)
292    /// and [`open_with_config_toml`](Self::open_with_config_toml) (engine
293    /// config) on a single handle.
294    ///
295    /// # Arguments
296    ///
297    /// * `path` - Path to the database directory (will be created if needed)
298    /// * `observer` - A Kotlin/Swift implementation of [`MobileObserver`]
299    /// * `config_toml` - TOML configuration string (engine sections only)
300    ///
301    /// # Errors
302    ///
303    /// Same fail-fast semantics as
304    /// [`open_with_config_toml`](Self::open_with_config_toml).
305    #[uniffi::constructor]
306    pub fn open_with_observer_and_config_toml(
307        path: String,
308        observer: Arc<dyn MobileObserver>,
309        config_toml: String,
310    ) -> Result<Arc<Self>, VelesError> {
311        let config = load_engine_config_from_toml(&config_toml)?;
312        let core_observer: Arc<dyn DatabaseObserver> = Arc::new(ForeignObserver::new(observer));
313        let db = CoreDatabase::open_with_observer_and_config(&path, core_observer, config)?;
314        Ok(Arc::new(Self {
315            inner: Arc::new(db),
316        }))
317    }
318
319    /// Updates query guardrail limits for every collection in this database.
320    ///
321    /// This is a full replacement: all fields of `limits` are applied.
322    pub fn update_guardrails(&self, limits: MobileQueryLimits) {
323        self.inner.update_guardrails(&limits.into());
324    }
325
326    /// Creates a new collection with the specified parameters.
327    ///
328    /// # Arguments
329    ///
330    /// * `name` - Unique name for the collection
331    /// * `dimension` - Vector dimension (e.g., 384, 768, 1536)
332    /// * `metric` - Distance metric for similarity calculations
333    pub fn create_collection(
334        &self,
335        name: String,
336        dimension: u32,
337        metric: DistanceMetric,
338    ) -> Result<(), VelesError> {
339        self.inner.create_collection(
340            &name,
341            usize::try_from(dimension).unwrap_or(usize::MAX),
342            metric.into(),
343        )?;
344        Ok(())
345    }
346
347    /// Creates a new collection with custom storage mode for IoT/Edge devices.
348    ///
349    /// # Arguments
350    ///
351    /// * `name` - Unique name for the collection
352    /// * `dimension` - Vector dimension
353    /// * `metric` - Distance metric
354    /// * `storage_mode` - Storage optimization (see [`StorageMode`])
355    ///
356    /// # Storage Modes
357    ///
358    /// - **Full**: Best recall, 4 bytes/dimension
359    /// - **Sq8**: 4x compression, ~1% recall loss (recommended for mobile)
360    /// - **Binary**: 32x compression, ~5-10% recall loss (for extreme constraints)
361    /// - **`ProductQuantization`**: 8x-16x compression via trained codebooks
362    ///   (requires a training step before upserts)
363    /// - **`Rabitq`**: 32x compression with ~1-2% recall loss (1-bit with
364    ///   rotation + scalar correction)
365    pub fn create_collection_with_storage(
366        &self,
367        name: String,
368        dimension: u32,
369        metric: DistanceMetric,
370        storage_mode: StorageMode,
371    ) -> Result<(), VelesError> {
372        self.inner.create_vector_collection_with_options(
373            &name,
374            usize::try_from(dimension).unwrap_or(usize::MAX),
375            metric.into(),
376            storage_mode.into(),
377        )?;
378        Ok(())
379    }
380
381    /// Creates a metadata-only collection (no vectors).
382    ///
383    /// Useful for storing reference data, lookups, or auxiliary information
384    /// that doesn't require vector similarity search.
385    ///
386    /// # Arguments
387    ///
388    /// * `name` - Unique name for the collection
389    pub fn create_metadata_collection(&self, name: String) -> Result<(), VelesError> {
390        self.inner.create_metadata_collection(&name)?;
391        Ok(())
392    }
393
394    /// Creates a graph collection for knowledge graph workloads.
395    ///
396    /// Creates a schemaless graph collection (no node embeddings).
397    /// For graph collections with node embeddings, use
398    /// [`create_graph_collection_with_embeddings`](Self::create_graph_collection_with_embeddings).
399    ///
400    /// # Arguments
401    ///
402    /// * `name` - Unique name for the collection
403    pub fn create_graph_collection(&self, name: String) -> Result<(), VelesError> {
404        self.inner
405            .create_graph_collection(&name, velesdb_core::GraphSchema::schemaless())?;
406        Ok(())
407    }
408
409    /// Creates a graph collection with node embeddings.
410    ///
411    /// Nodes in this collection can store vector embeddings and support
412    /// similarity search alongside graph traversal.
413    ///
414    /// # Arguments
415    ///
416    /// * `name` - Unique name for the collection
417    /// * `dimension` - Vector dimension for node embeddings
418    /// * `metric` - Distance metric for similarity calculations
419    pub fn create_graph_collection_with_embeddings(
420        &self,
421        name: String,
422        dimension: u32,
423        metric: DistanceMetric,
424    ) -> Result<(), VelesError> {
425        self.inner.create_graph_collection_with_embeddings(
426            &name,
427            velesdb_core::GraphSchema::schemaless(),
428            usize::try_from(dimension).unwrap_or(usize::MAX),
429            metric.into(),
430        )?;
431        Ok(())
432    }
433
434    /// Gets a vector collection by name.
435    ///
436    /// Returns `None` if the collection does not exist.
437    /// Returns an error if the collection exists but is not a vector collection.
438    /// Graph collections are queried through [`execute_query`](Self::execute_query)
439    /// (VelesQL); metadata collections are not retrievable through this accessor.
440    pub fn get_collection(&self, name: String) -> Result<Option<Arc<VelesCollection>>, VelesError> {
441        match self.inner.get_any_collection(&name) {
442            Some(any_coll) => match any_coll.into_vector() {
443                Ok(vc) => Ok(Some(Arc::new(VelesCollection {
444                    inner: vc,
445                    db: self.inner.clone(),
446                    name,
447                }))),
448                Err(_other_variant) => Err(VelesError::Collection {
449                    message: format!(
450                        "Collection '{name}' is not a vector collection. \
451                         Query graph collections through execute_query() (VelesQL)."
452                    ),
453                }),
454            },
455            None => Ok(None),
456        }
457    }
458
459    /// Lists all collection names.
460    pub fn list_collections(&self) -> Vec<String> {
461        self.inner.list_collections()
462    }
463
464    /// Deletes a collection by name.
465    pub fn delete_collection(&self, name: String) -> Result<(), VelesError> {
466        self.inner.delete_collection(&name)?;
467        Ok(())
468    }
469
470    /// Trains a Product Quantizer on a collection.
471    ///
472    /// PQ training is a database-level operation that requires access to the
473    /// VelesQL TRAIN executor.
474    ///
475    /// # Arguments
476    ///
477    /// * `collection_name` - Name of the collection to train PQ on
478    /// * `config` - PQ training configuration
479    ///
480    /// # Returns
481    ///
482    /// Status message from the training process.
483    pub fn train_pq(
484        &self,
485        collection_name: String,
486        config: PqTrainConfig,
487    ) -> Result<String, VelesError> {
488        use std::collections::HashMap;
489        use velesdb_core::velesql::{Query, TrainStatement, WithValue};
490
491        let mut params = HashMap::new();
492        params.insert("m".to_string(), WithValue::Integer(i64::from(config.m)));
493        params.insert("k".to_string(), WithValue::Integer(i64::from(config.k)));
494        if config.opq {
495            params.insert("type".to_string(), WithValue::Identifier("opq".to_string()));
496        }
497
498        let query = Query::new_train(TrainStatement {
499            collection: collection_name,
500            params,
501        });
502
503        let empty_params = HashMap::new();
504        self.inner
505            .execute_query(&query, &empty_params)
506            .map_err(|e| VelesError::database(format!("PQ training failed: {e}")))?;
507
508        Ok("PQ training complete".to_string())
509    }
510
511    /// Executes an arbitrary VelesQL query and returns structured results.
512    ///
513    /// This is the primary entry point for mobile apps to run the full
514    /// VelesQL surface: SELECT, INSERT, UPDATE, DELETE, MATCH, DDL
515    /// (CREATE/DROP/ALTER/TRUNCATE), TRAIN QUANTIZER, SHOW, DESCRIBE,
516    /// EXPLAIN, ANALYZE, and FLUSH.
517    ///
518    /// # Arguments
519    ///
520    /// * `sql` - VelesQL query string
521    /// * `params_json` - Optional JSON object with query parameters
522    ///   (keys are bare names; use `$name` syntax in SQL).
523    ///   Pass `None` or `"{}"` when no parameters are needed.
524    ///
525    /// # Returns
526    ///
527    /// A [`QueryResult`] containing the result kind, rows (as JSON strings),
528    /// row count, and a human-readable status message.
529    ///
530    /// # Example (Swift)
531    ///
532    /// ```swift
533    /// let result = try db.executeQuery(
534    ///     sql: "SELECT * FROM docs LIMIT 10",
535    ///     paramsJson: nil
536    /// )
537    /// for row in result.rows {
538    ///     let json = try JSONSerialization.jsonObject(with: row.dataJson.data(using: .utf8)!)
539    ///     print(json)
540    /// }
541    /// ```
542    pub fn execute_query(
543        &self,
544        sql: String,
545        params_json: Option<String>,
546    ) -> Result<QueryResult, VelesError> {
547        let parsed = velesdb_core::velesql::Parser::parse(&sql)
548            .map_err(|e| VelesError::database(format!("VelesQL parse error: {}", e.message)))?;
549
550        let params = query::parse_params(params_json)?;
551        let kind = query::classify_query(&parsed);
552
553        let core_results = self
554            .inner
555            .execute_query(&parsed, &params)
556            .map_err(|e| VelesError::database(format!("Query execution failed: {e}")))?;
557
558        let rows: Result<Vec<QueryResultRow>, VelesError> =
559            core_results.iter().map(query::to_result_row).collect();
560        let rows = rows?;
561
562        #[allow(clippy::cast_possible_truncation)]
563        // Reason: row count from a single query will not exceed u32::MAX.
564        let row_count = rows.len() as u32;
565        let message = query::build_message(&kind, row_count);
566
567        Ok(QueryResult {
568            kind,
569            rows,
570            row_count,
571            message,
572        })
573    }
574}
575
576// ============================================================================
577// Tests
578// ============================================================================
579
580#[cfg(test)]
581#[path = "lib_tests.rs"]
582mod tests;