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