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:
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);
+ }
+}