app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit caaf19a7c2c0c3b1b5a02c56cdfb6b309cff8388
parent e4433c33db4566ac37b203d3716bb3b274110a71
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 04:15:32 +0000

app-core: add web sql engine

- add sql cipher config and expanded sql error set
- implement web sql engine with encrypted idb store and backup export
- wire sql web module exports and add engine tests
- add rusqlite workspace dependencies for app core

Diffstat:
MCargo.lock | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 1+
Mcrates/core/Cargo.toml | 1+
Mcrates/core/src/sql/error.rs | 45++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/core/src/sql/mod.rs | 3+++
Mcrates/core/src/sql/types.rs | 9++++++++-
Acrates/core/src/sql/web.rs | 515+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 646 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -24,6 +24,18 @@ dependencies = [ ] [[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -494,6 +506,18 @@ dependencies = [ ] [[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] name = "find-msvc-tools" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -680,6 +704,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -688,6 +715,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1051,6 +1087,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] name = "linear-map" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1276,6 +1323,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1424,6 +1477,7 @@ dependencies = [ "getrandom 0.2.17", "js-sys", "radroots-nostr", + "rusqlite", "serde", "serde-wasm-bindgen", "serde_json", @@ -1580,6 +1634,20 @@ dependencies = [ ] [[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2146,6 +2214,12 @@ dependencies = [ ] [[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -62,6 +62,7 @@ web-sys = { version = "0.3.77", features = [ wasm-bindgen-futures = "0.4" base64 = "0.22" serde-wasm-bindgen = "0.6" +rusqlite = { version = "0.31", default-features = false } radroots-nostr = { path = "refs/crates/nostr" } [profile.release] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml @@ -16,6 +16,7 @@ serde_json = { workspace = true } getrandom = { workspace = true } base64 = { workspace = true } radroots-nostr = { workspace = true } +rusqlite = { workspace = true, features = ["bundled", "serialize"] } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = { workspace = true } diff --git a/crates/core/src/sql/error.rs b/crates/core/src/sql/error.rs @@ -3,6 +3,12 @@ use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsClientSqlError { IdbUndefined, + EngineUnavailable, + InvalidParams, + QueryFailure, + ExportFailure, + ImportFailure, + BackupFailure, } pub type RadrootsClientSqlErrorMessage = &'static str; @@ -11,6 +17,14 @@ impl RadrootsClientSqlError { pub const fn message(self) -> RadrootsClientSqlErrorMessage { match self { RadrootsClientSqlError::IdbUndefined => "error.client.sql.idb_undefined", + RadrootsClientSqlError::EngineUnavailable => { + "error.client.sql.engine_unavailable" + } + RadrootsClientSqlError::InvalidParams => "error.client.sql.invalid_params", + RadrootsClientSqlError::QueryFailure => "error.client.sql.query_failure", + RadrootsClientSqlError::ExportFailure => "error.client.sql.export_failure", + RadrootsClientSqlError::ImportFailure => "error.client.sql.import_failure", + RadrootsClientSqlError::BackupFailure => "error.client.sql.backup_failure", } } } @@ -29,7 +43,36 @@ mod tests { #[test] fn message_matches_spec() { - let cases = [(RadrootsClientSqlError::IdbUndefined, "error.client.sql.idb_undefined")]; + let cases = [ + ( + RadrootsClientSqlError::IdbUndefined, + "error.client.sql.idb_undefined", + ), + ( + RadrootsClientSqlError::EngineUnavailable, + "error.client.sql.engine_unavailable", + ), + ( + RadrootsClientSqlError::InvalidParams, + "error.client.sql.invalid_params", + ), + ( + RadrootsClientSqlError::QueryFailure, + "error.client.sql.query_failure", + ), + ( + RadrootsClientSqlError::ExportFailure, + "error.client.sql.export_failure", + ), + ( + RadrootsClientSqlError::ImportFailure, + "error.client.sql.import_failure", + ), + ( + RadrootsClientSqlError::BackupFailure, + "error.client.sql.backup_failure", + ), + ]; for (err, expected) in cases { assert_eq!(err.message(), expected); assert_eq!(err.to_string(), expected); diff --git a/crates/core/src/sql/mod.rs b/crates/core/src/sql/mod.rs @@ -1,8 +1,10 @@ pub mod error; pub mod types; +pub mod web; pub use error::{RadrootsClientSqlError, RadrootsClientSqlErrorMessage}; pub use types::{ + RadrootsClientSqlCipherConfig, RadrootsClientSqlEncryptedStore, RadrootsClientSqlEngine, RadrootsClientSqlEngineConfig, @@ -14,3 +16,4 @@ pub use types::{ RadrootsClientSqlResult, RadrootsClientSqlValue, }; +pub use web::RadrootsClientWebSqlEngine; diff --git a/crates/core/src/sql/types.rs b/crates/core/src/sql/types.rs @@ -38,10 +38,17 @@ pub enum RadrootsClientSqlParams { } #[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsClientSqlCipherConfig { + Default, + Disabled, + Custom(RadrootsClientIdbConfig), +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsClientSqlEngineConfig { pub store_key: String, pub idb_config: RadrootsClientIdbConfig, - pub cipher_config: Option<RadrootsClientIdbConfig>, + pub cipher_config: RadrootsClientSqlCipherConfig, pub sql_wasm_path: Option<String>, } diff --git a/crates/core/src/sql/web.rs b/crates/core/src/sql/web.rs @@ -0,0 +1,515 @@ +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use rusqlite::types::{Value as SqlValue, ValueRef as SqlValueRef}; +use rusqlite::{params_from_iter, Connection, DatabaseName}; +use serde_json::Value; + +use crate::backup::{backup_b64_to_bytes, backup_bytes_to_b64, RadrootsClientBackupSqlPayload}; +#[cfg(target_arch = "wasm32")] +use crate::crypto::RadrootsClientCryptoError; +use crate::crypto::RadrootsClientLegacyKeyConfig; +use crate::idb::{IDB_CONFIG_CIPHER_SQL, RadrootsClientIdbConfig}; +#[cfg(target_arch = "wasm32")] +use crate::idb::RadrootsClientIdbStoreError; +use crate::idb::{RadrootsClientWebEncryptedStore, RadrootsClientWebEncryptedStoreConfig}; + +use super::{ + RadrootsClientSqlCipherConfig, + RadrootsClientSqlEncryptedStore, + RadrootsClientSqlEngine, + RadrootsClientSqlEngineConfig, + RadrootsClientSqlError, + RadrootsClientSqlExecOutcome, + RadrootsClientSqlParams, + RadrootsClientSqlResult, + RadrootsClientSqlResultRow, + RadrootsClientSqlValue, +}; + +const SQL_STORE_PREFIX: &str = "sql"; +const DEFAULT_IV_LENGTH: u32 = 12; + +pub struct RadrootsClientWebSqlEncryptedStore { + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + store_key: String, + store_id: String, + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + encrypted_store: RadrootsClientWebEncryptedStore, +} + +impl RadrootsClientWebSqlEncryptedStore { + pub fn new(config: &RadrootsClientSqlEngineConfig) -> Self { + let store_key = config.store_key.clone(); + let store_id = format!("{SQL_STORE_PREFIX}:{store_key}"); + let legacy_idb_config = resolve_cipher_config(&config.cipher_config); + let legacy_key = legacy_idb_config.map(|idb_config| RadrootsClientLegacyKeyConfig { + idb_config, + key_name: format!("radroots.sql.{store_key}.aes-gcm.key"), + iv_length: DEFAULT_IV_LENGTH, + algorithm: String::from("AES-GCM"), + }); + let encrypted_store = RadrootsClientWebEncryptedStore::new( + RadrootsClientWebEncryptedStoreConfig { + idb_config: config.idb_config, + store_id: store_id.clone(), + legacy_key, + iv_length: Some(DEFAULT_IV_LENGTH), + crypto_service: None, + }, + ); + Self { + store_key, + store_id, + encrypted_store, + } + } + + pub fn get_store_id(&self) -> &str { + &self.store_id + } +} + +#[async_trait(?Send)] +impl RadrootsClientSqlEncryptedStore for RadrootsClientWebSqlEncryptedStore { + async fn load(&self) -> RadrootsClientSqlResult<Option<Vec<u8>>> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientSqlError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + self.encrypted_store + .ensure_store() + .await + .map_err(map_crypto_error)?; + let stored = crate::idb::idb_get( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + &self.store_key, + ) + .await + .map_err(map_idb_error)?; + let Some(stored) = stored else { + return Ok(None); + }; + let Some(bytes) = crate::idb::idb_value_as_bytes(&stored) else { + return Ok(None); + }; + let outcome = self + .encrypted_store + .decrypt_record(&bytes) + .await + .map_err(map_crypto_error)?; + if let Some(reencrypted) = outcome.reencrypted { + let value = js_sys::Uint8Array::from(&reencrypted[..]); + crate::idb::idb_set( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + &self.store_key, + &value.into(), + ) + .await + .map_err(map_idb_error)?; + } + Ok(Some(outcome.plaintext)) + } + } + + async fn save(&self, bytes: &[u8]) -> RadrootsClientSqlResult<()> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = bytes; + return Err(RadrootsClientSqlError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + self.encrypted_store + .ensure_store() + .await + .map_err(map_crypto_error)?; + let encrypted = self + .encrypted_store + .encrypt_bytes(bytes) + .await + .map_err(map_crypto_error)?; + let value = js_sys::Uint8Array::from(&encrypted[..]); + crate::idb::idb_set( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + &self.store_key, + &value.into(), + ) + .await + .map_err(map_idb_error)?; + Ok(()) + } + } + + async fn remove(&self) -> RadrootsClientSqlResult<()> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientSqlError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + crate::idb::idb_del( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + &self.store_key, + ) + .await + .map_err(map_idb_error)?; + Ok(()) + } + } +} + +pub struct RadrootsClientWebSqlEngine { + store_id: String, + store: Arc<RadrootsClientWebSqlEncryptedStore>, + conn: Arc<Mutex<Connection>>, +} + +impl RadrootsClientWebSqlEngine { + pub async fn create( + config: RadrootsClientSqlEngineConfig, + ) -> RadrootsClientSqlResult<Self> { + let store = Arc::new(RadrootsClientWebSqlEncryptedStore::new(&config)); + let conn = Connection::open_in_memory().map_err(map_rusqlite_error)?; + let engine = Self { + store_id: store.get_store_id().to_string(), + store, + conn: Arc::new(Mutex::new(conn)), + }; + match engine.store.load().await { + Ok(Some(bytes)) => { + let _ = engine.import_bytes(&bytes).await?; + } + Ok(None) => {} + Err(RadrootsClientSqlError::IdbUndefined) => {} + Err(err) => return Err(err), + } + Ok(engine) + } + + pub fn get_store_id(&self) -> &str { + &self.store_id + } + + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + pub(crate) fn shared_connection(&self) -> Arc<Mutex<Connection>> { + Arc::clone(&self.conn) + } + + fn exec_statement( + &self, + sql: &str, + params: RadrootsClientSqlParams, + ) -> RadrootsClientSqlResult<RadrootsClientSqlExecOutcome> { + let conn = self.conn.lock().map_err(|_| RadrootsClientSqlError::EngineUnavailable)?; + let mut stmt = conn.prepare(sql).map_err(map_rusqlite_error)?; + let changes = match params { + RadrootsClientSqlParams::Named(named) => { + let named = build_named_params(&named)?; + let mut refs = Vec::with_capacity(named.len()); + for (key, value) in &named { + refs.push((key.as_str(), value as &dyn rusqlite::ToSql)); + } + stmt.execute(refs.as_slice()).map_err(map_rusqlite_error)? + } + RadrootsClientSqlParams::Positional(values) => { + let values = build_positional_params(&values)?; + stmt.execute(params_from_iter(values.into_iter())) + .map_err(map_rusqlite_error)? + } + }; + let last_insert_id = conn.last_insert_rowid(); + Ok(RadrootsClientSqlExecOutcome { + changes: changes as i64, + last_insert_id, + }) + } + + fn query_statement( + &self, + sql: &str, + params: RadrootsClientSqlParams, + ) -> RadrootsClientSqlResult<Vec<RadrootsClientSqlResultRow>> { + let conn = self.conn.lock().map_err(|_| RadrootsClientSqlError::EngineUnavailable)?; + let mut stmt = conn.prepare(sql).map_err(map_rusqlite_error)?; + let rows = match params { + RadrootsClientSqlParams::Named(named) => { + let named = build_named_params(&named)?; + let mut refs = Vec::with_capacity(named.len()); + for (key, value) in &named { + refs.push((key.as_str(), value as &dyn rusqlite::ToSql)); + } + let mapped = stmt + .query_map(refs.as_slice(), row_to_map) + .map_err(map_rusqlite_error)?; + mapped.collect::<Result<Vec<_>, _>>().map_err(map_rusqlite_error)? + } + RadrootsClientSqlParams::Positional(values) => { + let values = build_positional_params(&values)?; + let mapped = stmt + .query_map(params_from_iter(values.into_iter()), row_to_map) + .map_err(map_rusqlite_error)?; + mapped.collect::<Result<Vec<_>, _>>().map_err(map_rusqlite_error)? + } + }; + Ok(rows) + } + + fn export_bytes_inner(&self) -> RadrootsClientSqlResult<Vec<u8>> { + let conn = self.conn.lock().map_err(|_| RadrootsClientSqlError::EngineUnavailable)?; + let data = conn + .serialize(DatabaseName::Main) + .map_err(|_| RadrootsClientSqlError::ExportFailure)?; + Ok(data.to_vec()) + } + + async fn persist(&self) -> RadrootsClientSqlResult<()> { + let bytes = self.export_bytes_inner()?; + match self.store.save(&bytes).await { + Ok(()) => Ok(()), + Err(RadrootsClientSqlError::IdbUndefined) => Ok(()), + Err(err) => Err(err), + } + } +} + +#[async_trait(?Send)] +impl RadrootsClientSqlEngine for RadrootsClientWebSqlEngine { + async fn close(&self) -> RadrootsClientSqlResult<()> { + self.persist().await + } + + async fn purge_storage(&self) -> RadrootsClientSqlResult<()> { + match self.store.remove().await { + Ok(()) => Ok(()), + Err(RadrootsClientSqlError::IdbUndefined) => Ok(()), + Err(err) => Err(err), + } + } + + fn exec( + &self, + sql: &str, + params: RadrootsClientSqlParams, + ) -> RadrootsClientSqlResult<RadrootsClientSqlExecOutcome> { + self.exec_statement(sql, params) + } + + fn query( + &self, + sql: &str, + params: RadrootsClientSqlParams, + ) -> RadrootsClientSqlResult<Vec<RadrootsClientSqlResultRow>> { + self.query_statement(sql, params) + } + + fn export_bytes(&self) -> RadrootsClientSqlResult<Vec<u8>> { + self.export_bytes_inner() + } + + async fn import_bytes(&self, _bytes: &[u8]) -> RadrootsClientSqlResult<()> { + Err(RadrootsClientSqlError::ImportFailure) + } + + async fn export_backup(&self) -> RadrootsClientSqlResult<RadrootsClientBackupSqlPayload> { + let bytes = self.export_bytes_inner()?; + let bytes_b64 = backup_bytes_to_b64(&bytes) + .map_err(|_| RadrootsClientSqlError::BackupFailure)?; + Ok(RadrootsClientBackupSqlPayload { bytes_b64 }) + } + + async fn import_backup( + &self, + payload: RadrootsClientBackupSqlPayload, + ) -> RadrootsClientSqlResult<()> { + let bytes = backup_b64_to_bytes(&payload.bytes_b64) + .map_err(|_| RadrootsClientSqlError::BackupFailure)?; + self.import_bytes(&bytes).await + } + + fn get_store_id(&self) -> &str { + &self.store_id + } +} + +fn resolve_cipher_config( + cipher_config: &RadrootsClientSqlCipherConfig, +) -> Option<RadrootsClientIdbConfig> { + match cipher_config { + RadrootsClientSqlCipherConfig::Default => Some(IDB_CONFIG_CIPHER_SQL), + RadrootsClientSqlCipherConfig::Disabled => None, + RadrootsClientSqlCipherConfig::Custom(config) => Some(*config), + } +} + +fn build_positional_params( + values: &[RadrootsClientSqlValue], +) -> RadrootsClientSqlResult<Vec<SqlValue>> { + let mut binds = Vec::with_capacity(values.len()); + for value in values { + binds.push(map_param_value(value)?); + } + Ok(binds) +} + +fn build_named_params( + values: &BTreeMap<String, RadrootsClientSqlValue>, +) -> RadrootsClientSqlResult<Vec<(String, SqlValue)>> { + let mut binds = Vec::with_capacity(values.len()); + for (key, value) in values { + let key = if key.starts_with(':') || key.starts_with('@') || key.starts_with('$') { + key.clone() + } else { + format!(":{key}") + }; + binds.push((key, map_param_value(value)?)); + } + Ok(binds) +} + +fn map_param_value(value: &RadrootsClientSqlValue) -> RadrootsClientSqlResult<SqlValue> { + match value { + Value::Null => Ok(SqlValue::Null), + Value::Bool(value) => Ok(SqlValue::Integer(i64::from(*value))), + Value::Number(value) => { + if let Some(v) = value.as_i64() { + Ok(SqlValue::Integer(v)) + } else if let Some(v) = value.as_u64() { + Ok(SqlValue::Integer(v as i64)) + } else if let Some(v) = value.as_f64() { + Ok(SqlValue::Real(v)) + } else { + Err(RadrootsClientSqlError::InvalidParams) + } + } + Value::String(value) => Ok(SqlValue::Text(value.clone())), + _ => Err(RadrootsClientSqlError::InvalidParams), + } +} + +fn row_to_map(row: &rusqlite::Row) -> rusqlite::Result<RadrootsClientSqlResultRow> { + let stmt = row.as_ref(); + let mut map = BTreeMap::new(); + for i in 0..stmt.column_count() { + let name = stmt.column_name(i).unwrap_or("").to_string(); + let value = row.get_ref(i)?; + let json_value = match value { + SqlValueRef::Null => Value::Null, + SqlValueRef::Integer(i) => Value::from(i), + SqlValueRef::Real(f) => Value::from(f), + SqlValueRef::Text(s) => { + let s = std::str::from_utf8(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + i, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; + Value::from(s.to_string()) + } + SqlValueRef::Blob(_) => Value::Null, + }; + map.insert(name, json_value); + } + Ok(map) +} + +#[cfg(target_arch = "wasm32")] +fn map_crypto_error(err: RadrootsClientCryptoError) -> RadrootsClientSqlError { + match err { + RadrootsClientCryptoError::IdbUndefined => RadrootsClientSqlError::IdbUndefined, + RadrootsClientCryptoError::CryptoUndefined => RadrootsClientSqlError::EngineUnavailable, + _ => RadrootsClientSqlError::QueryFailure, + } +} + +#[cfg(target_arch = "wasm32")] +fn map_idb_error(err: RadrootsClientIdbStoreError) -> RadrootsClientSqlError { + match err { + RadrootsClientIdbStoreError::IdbUndefined => RadrootsClientSqlError::IdbUndefined, + _ => RadrootsClientSqlError::QueryFailure, + } +} + +fn map_rusqlite_error(_err: rusqlite::Error) -> RadrootsClientSqlError { + RadrootsClientSqlError::QueryFailure +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::RadrootsClientWebSqlEngine; + use crate::idb::RadrootsClientIdbConfig; + use crate::sql::{ + RadrootsClientSqlCipherConfig, + RadrootsClientSqlEngine, + RadrootsClientSqlEngineConfig, + RadrootsClientSqlParams, + RadrootsClientSqlValue, + }; + + #[test] + fn sql_exec_query_roundtrip() { + let config = RadrootsClientSqlEngineConfig { + store_key: "test-store".to_string(), + idb_config: RadrootsClientIdbConfig::new("db", "store"), + cipher_config: RadrootsClientSqlCipherConfig::Disabled, + sql_wasm_path: None, + }; + let engine = futures::executor::block_on(RadrootsClientWebSqlEngine::create(config)) + .expect("engine"); + let _ = engine.exec( + "CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT)", + RadrootsClientSqlParams::Positional(Vec::new()), + ); + let _ = engine.exec( + "INSERT INTO test_items (name) VALUES (?)", + RadrootsClientSqlParams::Positional(vec![RadrootsClientSqlValue::from("rad")]), + ); + let rows = engine + .query( + "SELECT name FROM test_items WHERE id = ?", + RadrootsClientSqlParams::Positional(vec![RadrootsClientSqlValue::from(1)]), + ) + .expect("query"); + let name = rows + .first() + .and_then(|row| row.get("name")) + .and_then(|value| value.as_str()) + .expect("name"); + assert_eq!(name, "rad"); + } + + #[test] + fn sql_named_params_execute() { + let config = RadrootsClientSqlEngineConfig { + store_key: "test-store".to_string(), + idb_config: RadrootsClientIdbConfig::new("db", "store"), + cipher_config: RadrootsClientSqlCipherConfig::Disabled, + sql_wasm_path: None, + }; + let engine = futures::executor::block_on(RadrootsClientWebSqlEngine::create(config)) + .expect("engine"); + let _ = engine.exec( + "CREATE TABLE named_items (id INTEGER PRIMARY KEY, name TEXT)", + RadrootsClientSqlParams::Positional(Vec::new()), + ); + let mut named = BTreeMap::new(); + named.insert("name".to_string(), RadrootsClientSqlValue::from("rad")); + let outcome = engine + .exec( + "INSERT INTO named_items (name) VALUES (:name)", + RadrootsClientSqlParams::Named(named), + ) + .expect("insert"); + assert_eq!(outcome.changes, 1); + } +}