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, ¶ms)
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;