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