sdk

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

commit 35c13270bdea0826cda5d9ebb345efe575082666
parent 07ec66a3f7c6990b93d0682c205e3a689fa9bf3e
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 07:28:26 +0000

wasm: harden support coverage

- add the missing CRDT builder-error binding coverage\n- split SQL wasm executor host decoding into testable JSON helpers\n- cover SQL executor host results, native host errors, and export-lock behavior\n- bring handwritten WASM support scopes to 100 percent coverage

Diffstat:
Mcrates/events_codec_wasm/src/lib.rs | 11+++++++++++
Mcrates/sql_wasm_runtime/src/lib.rs | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
2 files changed, 258 insertions(+), 26 deletions(-)

diff --git a/crates/events_codec_wasm/src/lib.rs b/crates/events_codec_wasm/src/lib.rs @@ -1023,6 +1023,17 @@ mod tests { } #[test] + fn field_bindings_surface_builder_errors() { + let crdt_json = serde_json::json!({ + "change": sample_crdt_change(), + "author_pubkey": " " + }) + .to_string(); + + assert!(farm_crdt_change_tags(&crdt_json).is_err()); + } + + #[test] fn group_bindings_encode_to_json_when_input_is_valid() { let metadata = sample_group_metadata(); assert_tags_json(group_put_user_tags( diff --git a/crates/sql_wasm_runtime/src/lib.rs b/crates/sql_wasm_runtime/src/lib.rs @@ -2,6 +2,7 @@ use radroots_sql_core::{ExecOutcome, SqlError, SqlExecutor, utils}; use serde::de::DeserializeOwned; +use serde_json::Value; use std::cell::Cell; use std::sync::atomic::{AtomicBool, Ordering}; use wasm_bindgen::JsValue; @@ -25,9 +26,15 @@ extern "C" { #[cfg(not(target_arch = "wasm32"))] use std::sync::{Mutex, OnceLock}; +#[cfg(all(not(target_arch = "wasm32"), test))] +use std::collections::VecDeque; + #[cfg(not(target_arch = "wasm32"))] type RecordedCall = (String, String); +#[cfg(all(not(target_arch = "wasm32"), test))] +type NativeHostResult = Result<Value, SqlError>; + #[cfg(not(target_arch = "wasm32"))] fn exec_calls() -> &'static Mutex<Vec<RecordedCall>> { static EXEC_CALLS: OnceLock<Mutex<Vec<RecordedCall>>> = OnceLock::new(); @@ -46,6 +53,46 @@ fn export_calls() -> &'static Mutex<u64> { EXPORT_CALLS.get_or_init(|| Mutex::new(0)) } +#[cfg(all(not(target_arch = "wasm32"), test))] +fn exec_results() -> &'static Mutex<VecDeque<NativeHostResult>> { + static EXEC_RESULTS: OnceLock<Mutex<VecDeque<NativeHostResult>>> = OnceLock::new(); + EXEC_RESULTS.get_or_init(|| Mutex::new(VecDeque::new())) +} + +#[cfg(all(not(target_arch = "wasm32"), test))] +fn query_results() -> &'static Mutex<VecDeque<NativeHostResult>> { + static QUERY_RESULTS: OnceLock<Mutex<VecDeque<NativeHostResult>>> = OnceLock::new(); + QUERY_RESULTS.get_or_init(|| Mutex::new(VecDeque::new())) +} + +#[cfg(all(not(target_arch = "wasm32"), test))] +fn push_exec_result(result: NativeHostResult) { + let mut results = exec_results().lock().expect("exec results lock"); + results.push_back(result); +} + +#[cfg(all(not(target_arch = "wasm32"), test))] +fn push_query_result(result: NativeHostResult) { + let mut results = query_results().lock().expect("query results lock"); + results.push_back(result); +} + +#[cfg(all(not(target_arch = "wasm32"), test))] +fn take_exec_result() -> Option<NativeHostResult> { + exec_results() + .lock() + .expect("exec results lock") + .pop_front() +} + +#[cfg(all(not(target_arch = "wasm32"), test))] +fn take_query_result() -> Option<NativeHostResult> { + query_results() + .lock() + .expect("query results lock") + .pop_front() +} + #[cfg(not(target_arch = "wasm32"))] fn js_exec(sql: &str, params_json: &str) -> JsValue { let mut calls = exec_calls().lock().expect("exec calls lock"); @@ -95,26 +142,13 @@ impl SqlExecutor for WasmSqlExecutor { 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, - }) + let v = host_exec_json(sql, params_json)?; + Ok(exec_outcome_from_json(&v)) } 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()) + let v = host_query_json(sql, params_json)?; + Ok(query_raw_from_json(&v)) } fn begin(&self) -> Result<(), SqlError> { @@ -146,6 +180,51 @@ pub fn parse_json<T: DeserializeOwned>(s: &str) -> Result<T, SqlError> { utils::parse_json(s) } +fn host_exec_json(sql: &str, params_json: &str) -> Result<Value, SqlError> { + let js = exec(sql, params_json); + #[cfg(all(not(target_arch = "wasm32"), test))] + if let Some(result) = take_exec_result() { + return result; + } + sql_value_from_js(js) +} + +fn host_query_json(sql: &str, params_json: &str) -> Result<Value, SqlError> { + let js = query(sql, params_json); + #[cfg(all(not(target_arch = "wasm32"), test))] + if let Some(result) = take_query_result() { + return result; + } + sql_value_from_js(js) +} + +#[cfg(target_arch = "wasm32")] +fn sql_value_from_js(js: JsValue) -> Result<Value, SqlError> { + serde_wasm_bindgen::from_value(js).map_err(|e| SqlError::SerializationError(e.to_string())) +} + +#[cfg(not(target_arch = "wasm32"))] +fn sql_value_from_js(_js: JsValue) -> Result<Value, SqlError> { + Err(SqlError::UnsupportedPlatform) +} + +fn exec_outcome_from_json(v: &Value) -> ExecOutcome { + 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); + ExecOutcome { + changes, + last_insert_id, + } +} + +fn query_raw_from_json(v: &Value) -> String { + v.to_string() +} + pub fn err_js(err: SqlError) -> JsValue { err_js_value(err) } @@ -234,24 +313,43 @@ fn export_lock_blocked() -> bool { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::{ + collections::BTreeMap, + sync::{Mutex, OnceLock}, + }; - use radroots_sql_core::SqlError; + use radroots_sql_core::{SqlError, SqlExecutor}; + use serde_json::json; 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, + WasmSqlExecutor, begin_tx, commit_tx, err_js, exec, exec_calls, exec_outcome_from_json, + exec_results, export_bytes, export_calls, export_lock_active, export_lock_begin, + export_lock_end, parse_json, push_exec_result, push_query_result, query, query_calls, + query_raw_from_json, query_results, rollback_tx, with_export_lock_bypass, }; + fn native_test_lock() -> &'static Mutex<()> { + static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + TEST_LOCK.get_or_init(|| Mutex::new(())) + } + + fn reset_native_state() { + exec_calls().lock().expect("exec calls lock").clear(); + query_calls().lock().expect("query calls lock").clear(); + *export_calls().lock().expect("export calls lock") = 0; + exec_results().lock().expect("exec results lock").clear(); + query_results().lock().expect("query results lock").clear(); + export_lock_end(); + } + #[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(_)) - )); + assert_eq!( + parse_json::<BTreeMap<String, u64>>("{").unwrap_err().code(), + "ERR_SERIALIZATION" + ); } #[test] @@ -262,6 +360,9 @@ mod tests { #[test] fn exec_query_export_delegate_to_js_hooks() { + let _guard = native_test_lock().lock().expect("native test lock"); + reset_native_state(); + let _ = exec("select 1", "[]"); let _ = query("select 2", "[1]"); let _ = export_bytes(); @@ -276,6 +377,9 @@ mod tests { #[test] fn tx_helpers_emit_expected_savepoint_statements() { + let _guard = native_test_lock().lock().expect("native test lock"); + reset_native_state(); + begin_tx(); commit_tx(); rollback_tx(); @@ -303,6 +407,9 @@ mod tests { #[test] fn export_lock_tracks_state() { + let _guard = native_test_lock().lock().expect("native test lock"); + reset_native_state(); + assert!(!export_lock_active()); export_lock_begin().expect("begin export lock"); assert!(export_lock_active()); @@ -310,4 +417,118 @@ mod tests { export_lock_end(); assert!(!export_lock_active()); } + + #[test] + fn executor_decodes_exec_outcomes() { + let _guard = native_test_lock().lock().expect("native test lock"); + reset_native_state(); + + let executor = WasmSqlExecutor::default(); + push_exec_result(Ok(json!({"changes": 2, "lastInsertRowid": 99}))); + let outcome = executor + .exec("insert into listing values (?)", r#"["bin-1"]"#) + .expect("exec outcome"); + assert_eq!(outcome.changes, 2); + assert_eq!(outcome.last_insert_id, 99); + + push_exec_result(Ok(json!({"changes": 3, "last_insert_id": 101}))); + let outcome = executor + .exec("update listing set qty = ?", "[1]") + .expect("exec outcome"); + assert_eq!(outcome.changes, 3); + assert_eq!(outcome.last_insert_id, 101); + + let default_outcome = exec_outcome_from_json(&json!({})); + assert_eq!(default_outcome.changes, 0); + assert_eq!(default_outcome.last_insert_id, 0); + + let calls = exec_calls().lock().expect("exec calls lock").clone(); + assert!(calls.iter().any(|(sql, params)| { + sql == "insert into listing values (?)" && params == r#"["bin-1"]"# + })); + } + + #[test] + fn executor_decodes_query_results_and_host_errors() { + let _guard = native_test_lock().lock().expect("native test lock"); + reset_native_state(); + + let executor = WasmSqlExecutor::new(); + let rows = json!([{"id": "listing-1"}]); + push_query_result(Ok(rows.clone())); + assert_eq!( + executor + .query_raw("select id from listing", "[]") + .expect("query rows"), + query_raw_from_json(&rows) + ); + + assert_eq!( + executor + .query_raw("select id from listing", "[]") + .unwrap_err() + .code(), + "ERR_UNSUPPORTED_PLATFORM" + ); + + push_exec_result(Err(SqlError::SerializationError( + "host response was not an object".to_string(), + ))); + assert_eq!( + executor + .exec("insert into listing values (?)", "[]") + .unwrap_err() + .code(), + "ERR_SERIALIZATION" + ); + assert_eq!( + executor + .exec("insert into listing values (?)", "[]") + .unwrap_err() + .code(), + "ERR_UNSUPPORTED_PLATFORM" + ); + } + + #[test] + fn export_lock_rejects_nested_lock_and_write_trait_calls() { + let _guard = native_test_lock().lock().expect("native test lock"); + reset_native_state(); + + let executor = WasmSqlExecutor::new(); + export_lock_begin().expect("begin export lock"); + assert_eq!( + export_lock_begin().unwrap_err().to_string(), + "invalid argument: replica db export in progress" + ); + assert_eq!( + executor + .exec("insert into listing values (?)", "[]") + .unwrap_err() + .code(), + "ERR_INVALID_ARGUMENT" + ); + assert_eq!(executor.begin().unwrap_err().code(), "ERR_INVALID_ARGUMENT"); + assert_eq!( + executor.commit().unwrap_err().code(), + "ERR_INVALID_ARGUMENT" + ); + assert_eq!( + executor.rollback().unwrap_err().code(), + "ERR_INVALID_ARGUMENT" + ); + + with_export_lock_bypass(|| { + push_exec_result(Ok(json!({"changes": 1, "last_insert_id": 7}))); + let outcome = executor + .exec("insert into listing values (?)", "[]") + .expect("bypassed exec"); + assert_eq!(outcome.changes, 1); + executor.begin().expect("bypassed begin"); + executor.commit().expect("bypassed commit"); + executor.rollback().expect("bypassed rollback"); + }); + assert!(export_lock_active()); + export_lock_end(); + } }