sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 66015806a147824bad4c29ef9ae8e395d7838595
parent 054bd3575dcb2a6028afe7e354ba5ba6089529b8
Author: triesap <tyson@radroots.org>
Date:   Mon, 22 Jun 2026 01:06:52 +0000

wasm: move sql runtime into sdk

Diffstat:
MCargo.lock | 38+++++++++++++-------------------------
MCargo.toml | 3++-
Mcrates/replica_db_wasm/Cargo.toml | 6++----
Mcrates/replica_db_wasm/src/utils.rs | 2+-
Mcrates/replica_db_wasm/src/wasm_impl.rs | 9+++++----
Mcrates/replica_sync_wasm/Cargo.toml | 5+----
Mcrates/replica_sync_wasm/src/lib.rs | 2+-
Acrates/sql_wasm_runtime/Cargo.toml | 25+++++++++++++++++++++++++
Acrates/sql_wasm_runtime/src/lib.rs | 313+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 363 insertions(+), 40 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1897,8 +1897,8 @@ dependencies = [ "radroots_replica_db", "radroots_replica_db_schema", "radroots_replica_sync", + "radroots_sdk_sql_wasm_runtime", "radroots_sql_core", - "radroots_sql_wasm_core", "serde", "serde-wasm-bindgen", "serde_json", @@ -1932,8 +1932,7 @@ dependencies = [ "base64 0.22.1", "radroots_events", "radroots_replica_sync", - "radroots_sql_core", - "radroots_sql_wasm_core", + "radroots_sdk_sql_wasm_runtime", "serde", "serde-wasm-bindgen", "serde_json", @@ -2010,6 +2009,17 @@ name = "radroots_sdk_binding_model" version = "0.1.0" [[package]] +name = "radroots_sdk_sql_wasm_runtime" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_sql_core", + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", +] + +[[package]] name = "radroots_sdk_xtask" version = "0.1.0" dependencies = [ @@ -2033,32 +2043,10 @@ name = "radroots_sql_core" version = "0.1.0-alpha.2" dependencies = [ "chrono", - "radroots_sql_wasm_bridge", "rusqlite", "serde", - "serde-wasm-bindgen", "serde_json", "uuid", - "wasm-bindgen", -] - -[[package]] -name = "radroots_sql_wasm_bridge" -version = "0.1.0-alpha.2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "radroots_sql_wasm_core" -version = "0.1.0-alpha.2" -dependencies = [ - "radroots_sql_core", - "radroots_sql_wasm_bridge", - "serde", - "serde-wasm-bindgen", - "wasm-bindgen", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/replica_db_schema_bindings", "crates/replica_sync_wasm", "crates/sdk", + "crates/sql_wasm_runtime", "crates/trade_bindings", "crates/types_bindings", "crates/xtask", @@ -43,8 +44,8 @@ radroots_relay_transport = { path = "../lib/crates/relay_transport", version = " radroots_replica_db = { path = "../lib/crates/replica_db", version = "0.1.0-alpha.2", default-features = false } radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema", version = "0.1.0-alpha.2", default-features = false } radroots_replica_sync = { path = "../lib/crates/replica_sync", version = "0.1.0-alpha.2", default-features = false } +radroots_sdk_sql_wasm_runtime = { path = "crates/sql_wasm_runtime", version = "0.1.0-alpha.2" } radroots_sql_core = { path = "../lib/crates/sql_core", version = "0.1.0-alpha.2", default-features = false } -radroots_sql_wasm_core = { path = "../lib/crates/sql_wasm_core", version = "0.1.0-alpha.2", default-features = false } radroots_trade = { path = "../lib/crates/trade", version = "0.1.0-alpha.2", default-features = false, features = [ "serde_json", "std", diff --git a/crates/replica_db_wasm/Cargo.toml b/crates/replica_db_wasm/Cargo.toml @@ -16,10 +16,8 @@ readme = "README" crate-type = ["cdylib", "rlib"] [dependencies] -radroots_sql_core = { workspace = true, features = ["bridge"] } -radroots_sql_wasm_core = { workspace = true, default-features = false, features = [ - "bridge", -] } +radroots_sql_core = { workspace = true, features = ["web"] } +radroots_sdk_sql_wasm_runtime = { workspace = true } radroots_replica_db = { workspace = true } radroots_replica_db_schema = { workspace = true } radroots_replica_sync = { workspace = true, features = ["std"] } diff --git a/crates/replica_db_wasm/src/utils.rs b/crates/replica_db_wasm/src/utils.rs @@ -8,6 +8,6 @@ where T: Serialize, { let json = serde_json::to_string(&value) - .map_err(|err| radroots_sql_wasm_core::err_js(SqlError::from(err)))?; + .map_err(|err| radroots_sdk_sql_wasm_runtime::err_js(SqlError::from(err)))?; Ok(JsValue::from_str(&json)) } diff --git a/crates/replica_db_wasm/src/wasm_impl.rs b/crates/replica_db_wasm/src/wasm_impl.rs @@ -2,10 +2,11 @@ use crate::utils::value_to_js; use radroots_replica_db::migrations; use radroots_replica_db::{ReplicaDbExportManifestRs, export_manifest}; use radroots_replica_sync::radroots_replica_sync_status; -use radroots_sql_core::{ - WasmSqlExecutor, export_lock_begin, export_lock_end, with_export_lock_bypass, +use radroots_sdk_sql_wasm_runtime::parse_json; +use radroots_sdk_sql_wasm_runtime::{ + WasmSqlExecutor, err_js, export_bytes, export_lock_begin, export_lock_end, + with_export_lock_bypass, }; -use radroots_sql_wasm_core::{err_js, parse_json}; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; @@ -149,7 +150,7 @@ fn export_snapshot(exec: &WasmSqlExecutor) -> Result<JsValue, JsValue> { } fn export_snapshot_value(manifest: ReplicaDbExportManifestRs) -> Result<JsValue, JsValue> { - let bytes_js = radroots_sql_wasm_core::export_bytes(); + let bytes_js = export_bytes(); export_snapshot_value_with_bytes(manifest, bytes_js) } diff --git a/crates/replica_sync_wasm/Cargo.toml b/crates/replica_sync_wasm/Cargo.toml @@ -20,10 +20,7 @@ base64 = { workspace = true } radroots_events = { workspace = true, default-features = false, features = [ "serde", ] } -radroots_sql_core = { workspace = true, features = ["bridge"] } -radroots_sql_wasm_core = { workspace = true, default-features = false, features = [ - "bridge", -] } +radroots_sdk_sql_wasm_runtime = { workspace = true } radroots_replica_sync = { workspace = true, features = ["std"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/replica_sync_wasm/src/lib.rs b/crates/replica_sync_wasm/src/lib.rs @@ -13,7 +13,7 @@ use radroots_replica_sync::{ radroots_replica_ingest_event_with_factory, radroots_replica_sync_all, }; #[cfg(target_arch = "wasm32")] -use radroots_sql_core::WasmSqlExecutor; +use radroots_sdk_sql_wasm_runtime::WasmSqlExecutor; #[cfg(target_arch = "wasm32")] use serde::Deserialize; #[cfg(target_arch = "wasm32")] diff --git a/crates/sql_wasm_runtime/Cargo.toml b/crates/sql_wasm_runtime/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "radroots_sdk_sql_wasm_runtime" +publish = false +version = "0.1.0-alpha.2" +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license.workspace = true +description = "SDK-owned SQL WebAssembly host runtime" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_sdk_sql_wasm_runtime" + +[lib] +crate-type = ["rlib"] + +[dependencies] +radroots_sql_core = { workspace = true, features = ["web"] } +serde = { workspace = true } +serde_json = { workspace = true } +serde-wasm-bindgen = { workspace = true } +wasm-bindgen = { workspace = true } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/sql_wasm_runtime/src/lib.rs b/crates/sql_wasm_runtime/src/lib.rs @@ -0,0 +1,313 @@ +#![forbid(unsafe_code)] + +use radroots_sql_core::{ExecOutcome, SqlError, SqlExecutor, utils}; +use serde::de::DeserializeOwned; +use std::cell::Cell; +use std::sync::atomic::{AtomicBool, Ordering}; +use wasm_bindgen::JsValue; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_name = __radroots_sql_wasm_exec)] + fn js_exec(sql: &str, params_json: &str) -> JsValue; + + #[wasm_bindgen(js_name = __radroots_sql_wasm_query)] + fn js_query(sql: &str, params_json: &str) -> JsValue; + + #[wasm_bindgen(js_name = __radroots_sql_wasm_export_bytes)] + fn js_export_bytes() -> JsValue; +} + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::{Mutex, OnceLock}; + +#[cfg(not(target_arch = "wasm32"))] +type RecordedCall = (String, String); + +#[cfg(not(target_arch = "wasm32"))] +fn exec_calls() -> &'static Mutex<Vec<RecordedCall>> { + static EXEC_CALLS: OnceLock<Mutex<Vec<RecordedCall>>> = OnceLock::new(); + EXEC_CALLS.get_or_init(|| Mutex::new(Vec::new())) +} + +#[cfg(not(target_arch = "wasm32"))] +fn query_calls() -> &'static Mutex<Vec<RecordedCall>> { + static QUERY_CALLS: OnceLock<Mutex<Vec<RecordedCall>>> = OnceLock::new(); + QUERY_CALLS.get_or_init(|| Mutex::new(Vec::new())) +} + +#[cfg(not(target_arch = "wasm32"))] +fn export_calls() -> &'static Mutex<u64> { + static EXPORT_CALLS: OnceLock<Mutex<u64>> = OnceLock::new(); + EXPORT_CALLS.get_or_init(|| Mutex::new(0)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn js_exec(sql: &str, params_json: &str) -> JsValue { + let mut calls = exec_calls().lock().expect("exec calls lock"); + calls.push((sql.to_string(), params_json.to_string())); + JsValue::NULL +} + +#[cfg(not(target_arch = "wasm32"))] +fn js_query(sql: &str, params_json: &str) -> JsValue { + let mut calls = query_calls().lock().expect("query calls lock"); + calls.push((sql.to_string(), params_json.to_string())); + JsValue::NULL +} + +#[cfg(not(target_arch = "wasm32"))] +fn js_export_bytes() -> JsValue { + let mut calls = export_calls().lock().expect("export calls lock"); + *calls += 1; + JsValue::NULL +} + +const SAVEPOINT: &str = "radroots_schema_tx"; +const EXPORT_LOCK_ERR: &str = "replica db export in progress"; + +static EXPORT_LOCK_ACTIVE: AtomicBool = AtomicBool::new(false); + +thread_local! { + static EXPORT_LOCK_BYPASS: Cell<bool> = const { Cell::new(false) }; +} + +pub struct WasmSqlExecutor; + +impl WasmSqlExecutor { + pub fn new() -> Self { + Self + } +} + +impl Default for WasmSqlExecutor { + fn default() -> Self { + Self::new() + } +} + +impl SqlExecutor for WasmSqlExecutor { + fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { + if export_lock_blocked() { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } + let js = exec(sql, params_json); + let v: serde_json::Value = serde_wasm_bindgen::from_value(js) + .map_err(|e| SqlError::SerializationError(e.to_string()))?; + let changes = v.get("changes").and_then(|x| x.as_i64()).unwrap_or(0); + let last_insert_id = v + .get("last_insert_id") + .or_else(|| v.get("lastInsertRowid")) + .and_then(|x| x.as_i64()) + .unwrap_or(0); + Ok(ExecOutcome { + changes, + last_insert_id, + }) + } + + fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> { + let js = query(sql, params_json); + let v: serde_json::Value = serde_wasm_bindgen::from_value(js) + .map_err(|e| SqlError::SerializationError(e.to_string()))?; + Ok(v.to_string()) + } + + fn begin(&self) -> Result<(), SqlError> { + if export_lock_blocked() { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } + begin_tx(); + Ok(()) + } + + fn commit(&self) -> Result<(), SqlError> { + if export_lock_blocked() { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } + commit_tx(); + Ok(()) + } + + fn rollback(&self) -> Result<(), SqlError> { + if export_lock_blocked() { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } + rollback_tx(); + Ok(()) + } +} + +pub fn parse_json<T: DeserializeOwned>(s: &str) -> Result<T, SqlError> { + utils::parse_json(s) +} + +pub fn err_js(err: SqlError) -> JsValue { + err_js_value(err) +} + +#[cfg(target_arch = "wasm32")] +fn err_js_value(err: SqlError) -> JsValue { + match err_js_with_encoder(err, |err| { + let value = err.to_json(); + serde_wasm_bindgen::to_value(&value).map_err(|_| ()) + }) { + Ok(value) => value, + Err(err) => JsValue::from_str(&err.to_string()), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn err_js_value(err: SqlError) -> JsValue { + let _ = err.to_json(); + JsValue::NULL +} + +#[cfg(target_arch = "wasm32")] +fn err_js_with_encoder( + err: SqlError, + encode: impl FnOnce(&SqlError) -> Result<JsValue, ()>, +) -> Result<JsValue, SqlError> { + encode(&err).map_err(|()| err) +} + +pub fn exec(sql: &str, params_json: &str) -> JsValue { + js_exec(sql, params_json) +} + +pub fn query(sql: &str, params_json: &str) -> JsValue { + js_query(sql, params_json) +} + +pub fn export_bytes() -> JsValue { + js_export_bytes() +} + +pub fn begin_tx() { + let _ = js_exec(&format!("savepoint {}", SAVEPOINT), "[]"); +} + +pub fn commit_tx() { + let _ = js_exec(&format!("release savepoint {}", SAVEPOINT), "[]"); +} + +pub fn rollback_tx() { + let _ = js_exec(&format!("rollback to savepoint {}", SAVEPOINT), "[]"); + let _ = js_exec(&format!("release savepoint {}", SAVEPOINT), "[]"); +} + +pub fn export_lock_begin() -> Result<(), SqlError> { + let was_active = EXPORT_LOCK_ACTIVE.swap(true, Ordering::SeqCst); + if was_active { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } + Ok(()) +} + +pub fn export_lock_end() { + EXPORT_LOCK_ACTIVE.store(false, Ordering::SeqCst); +} + +pub fn export_lock_active() -> bool { + EXPORT_LOCK_ACTIVE.load(Ordering::SeqCst) +} + +pub fn with_export_lock_bypass<T>(f: impl FnOnce() -> T) -> T { + EXPORT_LOCK_BYPASS.with(|flag| { + let prev = flag.replace(true); + let out = f(); + flag.set(prev); + out + }) +} + +fn export_lock_blocked() -> bool { + if !EXPORT_LOCK_ACTIVE.load(Ordering::SeqCst) { + return false; + } + EXPORT_LOCK_BYPASS.with(|flag| !flag.get()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use radroots_sql_core::SqlError; + + use super::{ + begin_tx, commit_tx, err_js, exec, exec_calls, export_bytes, export_calls, + export_lock_active, export_lock_begin, export_lock_end, parse_json, query, query_calls, + rollback_tx, with_export_lock_bypass, + }; + + #[test] + fn parse_json_reports_valid_and_invalid_payloads() { + let parsed: BTreeMap<String, u64> = parse_json(r#"{"count":2}"#).expect("parse json"); + assert_eq!(parsed.get("count"), Some(&2)); + assert!(matches!( + parse_json::<BTreeMap<String, u64>>("{"), + Err(SqlError::SerializationError(_)) + )); + } + + #[test] + fn err_js_accepts_sql_errors() { + let _ = err_js(SqlError::Internal); + let _ = err_js(SqlError::UnsupportedPlatform); + } + + #[test] + fn exec_query_export_delegate_to_js_hooks() { + let _ = exec("select 1", "[]"); + let _ = query("select 2", "[1]"); + let _ = export_bytes(); + + let exec_len = exec_calls().lock().map(|calls| calls.len()).unwrap_or(0); + let query_len = query_calls().lock().map(|calls| calls.len()).unwrap_or(0); + let export_len = export_calls().lock().map(|calls| *calls).unwrap_or(0); + assert!(exec_len >= 1); + assert!(query_len >= 1); + assert!(export_len >= 1); + } + + #[test] + fn tx_helpers_emit_expected_savepoint_statements() { + begin_tx(); + commit_tx(); + rollback_tx(); + + let calls = exec_calls() + .lock() + .map(|calls| calls.clone()) + .unwrap_or_default(); + assert!( + calls + .iter() + .any(|(sql, _)| sql == "savepoint radroots_schema_tx") + ); + assert!( + calls + .iter() + .any(|(sql, _)| sql == "release savepoint radroots_schema_tx") + ); + assert!( + calls + .iter() + .any(|(sql, _)| sql == "rollback to savepoint radroots_schema_tx") + ); + } + + #[test] + fn export_lock_tracks_state() { + assert!(!export_lock_active()); + export_lock_begin().expect("begin export lock"); + assert!(export_lock_active()); + assert!(with_export_lock_bypass(|| true)); + export_lock_end(); + assert!(!export_lock_active()); + } +}