From 858df878aaa723ed8ce8a20cfc32a9ac3449935b Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 10:18:07 +0300 Subject: [PATCH 01/10] split database and leadership logic to different workers --- Cargo.lock | 4 +- packages/sqlite-web-core/src/coordination.rs | 1297 +++++++++--------- packages/sqlite-web-core/src/messages.rs | 12 + packages/sqlite-web-core/src/worker.rs | 452 +----- packages/sqlite-web/src/worker_template.rs | 10 +- 5 files changed, 749 insertions(+), 1026 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27c39c0..20c807d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4070,7 +4070,7 @@ dependencies = [ [[package]] name = "sqlite-web" -version = "0.0.1-alpha.7" +version = "0.0.1-alpha.8" dependencies = [ "base64 0.21.7", "js-sys", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "sqlite-web-core" -version = "0.0.1-alpha.7" +version = "0.0.1-alpha.8" dependencies = [ "alloy", "base64 0.21.7", diff --git a/packages/sqlite-web-core/src/coordination.rs b/packages/sqlite-web-core/src/coordination.rs index 9f342bb..45134b0 100644 --- a/packages/sqlite-web-core/src/coordination.rs +++ b/packages/sqlite-web-core/src/coordination.rs @@ -6,10 +6,15 @@ use uuid::Uuid; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::{spawn_local, JsFuture}; -use web_sys::{BroadcastChannel, DedicatedWorkerGlobalScope}; +use web_sys::{ + Blob, BlobPropertyBag, BroadcastChannel, DedicatedWorkerGlobalScope, MessageEvent, Url, Worker, +}; use crate::database::SQLiteDatabase; -use crate::messages::{ChannelMessage, PendingQuery, WORKER_ERROR_TYPE_INITIALIZATION_PENDING}; +use crate::messages::{ + ChannelMessage, MainThreadMessage, WorkerErrorPayload, WorkerMessage, + WORKER_ERROR_TYPE_INITIALIZATION_PENDING, +}; use crate::util::{js_value_to_string, sanitize_identifier, set_js_property}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -18,143 +23,107 @@ pub enum LeadershipRole { Follower, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LeaderPresence { - Known, - Unknown, -} - -// Worker state -pub struct WorkerState { - pub worker_id: String, - pub is_leader: Rc>, - pub has_leader: Rc>, - pub db: Rc>>, - pub channel: BroadcastChannel, +pub struct WorkerConfig { pub db_name: String, - pub pending_queries: Rc>>, pub follower_timeout_ms: f64, } -fn reflect_get(target: &JsValue, key: &str) -> Result { - Reflect::get(target, &JsValue::from_str(key)) -} +pub fn worker_config_from_global() -> Result { + fn get_db_name_from_global() -> Result { + let global = js_sys::global(); + let val = Reflect::get(&global, &JsValue::from_str("__SQLITE_DB_NAME")) + .unwrap_or(JsValue::UNDEFINED); + if let Some(s) = val.as_string() { + let trimmed = s.trim().to_string(); + if trimmed.is_empty() { + return Err(JsValue::from_str("Database name is required")); + } + Ok(trimmed) + } else { + #[cfg(test)] + { + return Ok("testdb".to_string()); + } + #[allow(unreachable_code)] + Err(JsValue::from_str("Database name is required")) + } + } -fn send_channel_message( - channel: &BroadcastChannel, - message: &ChannelMessage, -) -> Result<(), String> { - let value = serde_wasm_bindgen::to_value(message) - .map_err(|err| format!("Failed to serialize channel message: {err:?}"))?; - channel.post_message(&value).map_err(|err| { - format!( - "Failed to post channel message: {}", - js_value_to_string(&err) - ) + fn get_follower_timeout_from_global() -> f64 { + let global = js_sys::global(); + let val = Reflect::get(&global, &JsValue::from_str("__SQLITE_FOLLOWER_TIMEOUT_MS")) + .unwrap_or(JsValue::UNDEFINED); + if let Some(n) = val.as_f64() { + if n.is_finite() && n >= 0.0 { + return n; + } + } + 5000.0 + } + + Ok(WorkerConfig { + db_name: get_db_name_from_global()?, + follower_timeout_ms: get_follower_timeout_from_global(), }) } -fn post_worker_message(obj: &js_sys::Object) -> Result<(), String> { - let global = js_sys::global(); - let scope: DedicatedWorkerGlobalScope = global - .dyn_into() - .map_err(|_| "Failed to access worker scope".to_string())?; - scope - .post_message(obj.as_ref()) - .map_err(|err| js_value_to_string(&err)) +enum DbRequestOrigin { + Local { request_id: u32 }, + Forwarded { query_id: String }, } -fn send_worker_ready_message() -> Result<(), String> { - let message = js_sys::Object::new(); - set_js_property(&message, "type", &JsValue::from_str("worker-ready")) - .map_err(|err| js_value_to_string(&err))?; - post_worker_message(&message) +pub struct CoordinatorState { + pub worker_id: String, + pub role: Rc>, + pub leader_id: Rc>>, + pub leader_ready: Rc>, + pub ready_signaled: Rc>, + pub follower_timeout_ms: f64, + pub channel: BroadcastChannel, + pub db_worker_ready: Rc>, + pub db_worker: Rc>>, + pub db_name: String, + db_pending: Rc>>, + pub follower_pending: Rc>>, + pub next_db_request_id: Rc>, } -fn send_worker_error_message(error: &str) -> Result<(), String> { - let message = js_sys::Object::new(); - set_js_property(&message, "type", &JsValue::from_str("worker-error")) - .map_err(|err| js_value_to_string(&err))?; - set_js_property(&message, "error", &JsValue::from_str(error)) - .map_err(|err| js_value_to_string(&err))?; - post_worker_message(&message) +pub struct DbWorkerState { + pub db: Rc>>, + pub db_name: String, } -impl WorkerState { - pub fn new() -> Result { - fn get_db_name_from_global() -> Result { - let global = js_sys::global(); - let val = Reflect::get(&global, &JsValue::from_str("__SQLITE_DB_NAME")) - .unwrap_or(JsValue::UNDEFINED); - if let Some(s) = val.as_string() { - let trimmed = s.trim().to_string(); - if trimmed.is_empty() { - return Err(JsValue::from_str("Database name is required")); - } - Ok(trimmed) - } else { - #[cfg(test)] - { - return Ok("testdb".to_string()); - } - #[allow(unreachable_code)] - Err(JsValue::from_str("Database name is required")) - } - } - - fn get_follower_timeout_from_global() -> f64 { - let global = js_sys::global(); - let val = Reflect::get(&global, &JsValue::from_str("__SQLITE_FOLLOWER_TIMEOUT_MS")) - .unwrap_or(JsValue::UNDEFINED); - if let Some(n) = val.as_f64() { - if n.is_finite() && n >= 0.0 { - return n; - } - } - 5000.0 - } - - let worker_id = Uuid::new_v4().to_string(); - let db_name_raw = get_db_name_from_global()?; - let channel_name = format!("sqlite-queries-{}", sanitize_identifier(&db_name_raw)); - let channel = BroadcastChannel::new(&channel_name)?; - let follower_timeout_ms = get_follower_timeout_from_global(); +pub fn create_broadcast_channel(db_name: &str) -> Result { + let channel_name = format!("sqlite-queries-{}", sanitize_identifier(db_name)); + BroadcastChannel::new(&channel_name) +} - Ok(WorkerState { - worker_id, - is_leader: Rc::new(RefCell::new(LeadershipRole::Follower)), - has_leader: Rc::new(RefCell::new(LeaderPresence::Unknown)), - db: Rc::new(RefCell::new(None)), - channel, - db_name: db_name_raw, - pending_queries: Rc::new(RefCell::new(HashMap::new())), - follower_timeout_ms, - }) +impl CoordinatorState { + pub fn new(config: WorkerConfig) -> Result, JsValue> { + Ok(Rc::new(CoordinatorState { + worker_id: Uuid::new_v4().to_string(), + role: Rc::new(RefCell::new(LeadershipRole::Follower)), + leader_id: Rc::new(RefCell::new(None)), + leader_ready: Rc::new(RefCell::new(false)), + ready_signaled: Rc::new(RefCell::new(false)), + follower_timeout_ms: config.follower_timeout_ms, + channel: create_broadcast_channel(&config.db_name)?, + db_worker_ready: Rc::new(RefCell::new(false)), + db_worker: Rc::new(RefCell::new(None)), + db_name: config.db_name, + db_pending: Rc::new(RefCell::new(HashMap::new())), + follower_pending: Rc::new(RefCell::new(HashMap::new())), + next_db_request_id: Rc::new(RefCell::new(1)), + })) } - pub fn setup_channel_listener(&self) -> Result<(), JsValue> { - let is_leader = Rc::clone(&self.is_leader); - let has_leader = Rc::clone(&self.has_leader); - let db = Rc::clone(&self.db); - let pending_queries = Rc::clone(&self.pending_queries); - let channel = self.channel.clone(); - let worker_id = self.worker_id.clone(); - - let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { - let data = event.data(); - if let Ok(msg) = serde_wasm_bindgen::from_value::(data) { - handle_channel_message( - &is_leader, - &has_leader, - &db, - &channel, - &pending_queries, - &worker_id, - msg, - ); + pub fn setup_channel_listener(self: &Rc) -> Result<(), JsValue> { + let state = Rc::clone(self); + let onmessage = Closure::wrap(Box::new(move |event: MessageEvent| { + if let Ok(msg) = serde_wasm_bindgen::from_value::(event.data()) { + state.handle_channel_message(msg); } - }) as Box); - + }) as Box); self.channel .set_onmessage(Some(onmessage.as_ref().unchecked_ref())); onmessage.forget(); @@ -162,26 +131,26 @@ impl WorkerState { } pub fn start_leader_probe(self: &Rc) { - if matches!(*self.is_leader.borrow(), LeadershipRole::Leader) { + if matches!(*self.role.borrow(), LeadershipRole::Leader) { return; } - let has_leader = Rc::clone(&self.has_leader); - let channel = self.channel.clone(); + let has_leader = Rc::clone(&self.leader_id); + let timeout_ms = self.follower_timeout_ms; let worker_id = self.worker_id.clone(); - let follower_timeout_ms = self.follower_timeout_ms; + let channel = self.channel.clone(); spawn_local(async move { const POLL_INTERVAL_MS: f64 = 250.0; - let mut remaining_ms = if follower_timeout_ms.is_finite() { - follower_timeout_ms.max(0.0) + let mut remaining_ms = if timeout_ms.is_finite() { + timeout_ms.max(0.0) } else { f64::INFINITY }; if remaining_ms <= 0.0 { - if matches!(*has_leader.borrow(), LeaderPresence::Unknown) { + if has_leader.borrow().is_none() { let message = format!( "Leader election timed out after {:.0}ms", - follower_timeout_ms.max(0.0) + timeout_ms.max(0.0) ); let _ = send_worker_error_message(&message); } @@ -189,7 +158,7 @@ impl WorkerState { } while remaining_ms.is_infinite() || remaining_ms > 0.0 { - if matches!(*has_leader.borrow(), LeaderPresence::Known) { + if has_leader.borrow().is_some() { break; } let ping = ChannelMessage::LeaderPing { @@ -199,9 +168,6 @@ impl WorkerState { let _ = send_worker_error_message(&err_msg); break; } - if matches!(*has_leader.borrow(), LeaderPresence::Known) { - break; - } let sleep_duration = if remaining_ms.is_infinite() { POLL_INTERVAL_MS @@ -216,234 +182,615 @@ impl WorkerState { remaining_ms -= sleep_duration; } } - if matches!(*has_leader.borrow(), LeaderPresence::Unknown) { - let timeout = follower_timeout_ms.max(0.0); + if has_leader.borrow().is_none() { + let timeout = timeout_ms.max(0.0); let message = format!("Leader election timed out after {:.0}ms", timeout); let _ = send_worker_error_message(&message); } }); } - pub async fn attempt_leadership(&self) -> Result<(), JsValue> { - let worker_id = self.worker_id.clone(); - let is_leader = Rc::clone(&self.is_leader); - let has_leader = Rc::clone(&self.has_leader); - let db = Rc::clone(&self.db); - let channel = self.channel.clone(); - let db_name_for_handler = self.db_name.clone(); + pub fn try_become_leader(self: &Rc) { + let state = Rc::clone(self); + spawn_local(async move { + if let Err(err) = state.acquire_lock_and_promote().await { + let _ = send_worker_error_message(&js_value_to_string(&err)); + state.promote_without_lock(); + } + }); + } - // Get navigator.locks from WorkerGlobalScope + async fn acquire_lock_and_promote(self: &Rc) -> Result<(), JsValue> { let global = js_sys::global(); - let navigator = reflect_get(&global, "navigator")?; - let locks = reflect_get(&navigator, "locks")?; + let navigator = Reflect::get(&global, &JsValue::from_str("navigator"))?; + let locks = Reflect::get(&navigator, &JsValue::from_str("locks"))?; + let request_value = Reflect::get(&locks, &JsValue::from_str("request"))?; + let Some(request_fn) = request_value.dyn_ref::() else { + self.promote_without_lock(); + return Ok(()); + }; let options = Object::new(); set_js_property(&options, "mode", &JsValue::from_str("exclusive"))?; - + let lock_id = format!("sqlite-database-{}", sanitize_identifier(&self.db_name)); + let state = Rc::clone(self); let handler = Closure::once(move |_lock: JsValue| -> Promise { - *is_leader.borrow_mut() = LeadershipRole::Leader; - *has_leader.borrow_mut() = LeaderPresence::Known; - - let db = Rc::clone(&db); - let channel = channel.clone(); - let worker_id = worker_id.clone(); - let db_name = db_name_for_handler.clone(); - let has_leader_inner = Rc::clone(&has_leader); - - spawn_local(async move { - match SQLiteDatabase::initialize_opfs(&db_name).await { - Ok(database) => { - *db.borrow_mut() = Some(database); - *has_leader_inner.borrow_mut() = LeaderPresence::Known; - - let msg = ChannelMessage::NewLeader { - leader_id: worker_id.clone(), - }; - if let Err(err_msg) = send_channel_message(&channel, &msg) { - let fallback = ChannelMessage::QueryResponse { - query_id: worker_id.clone(), - result: None, - error: Some(err_msg), - }; - let _ = send_channel_message(&channel, &fallback); - } - if let Err(err_msg) = send_worker_ready_message() { - let _ = send_worker_error_message(&err_msg); - } - } - Err(err) => { - let msg = js_value_to_string(&err); - *has_leader_inner.borrow_mut() = LeaderPresence::Unknown; - let _ = send_worker_error_message(&msg); - } - } - }); - - // Never resolve = hold lock forever + state.on_lock_granted(); Promise::new(&mut |_, _| {}) }); - let request_fn = reflect_get(&locks, "request")?; - let request_fn = request_fn - .dyn_ref::() - .ok_or_else(|| JsValue::from_str("navigator.locks.request is not a function"))?; - - let lock_id: String = format!("sqlite-database-{}", sanitize_identifier(&self.db_name)); request_fn.call3( &locks, &JsValue::from_str(&lock_id), &options, handler.as_ref().unchecked_ref(), )?; - handler.forget(); Ok(()) } - pub async fn execute_query( - &self, - sql: String, - params: Option>, - ) -> Result { - if matches!(*self.is_leader.borrow(), LeadershipRole::Leader) { - exec_on_db(Rc::clone(&self.db), sql, params).await - } else { - if matches!(*self.has_leader.borrow(), LeaderPresence::Unknown) { - return Err(WORKER_ERROR_TYPE_INITIALIZATION_PENDING.to_string()); - } - let query_id = Uuid::new_v4().to_string(); - - let promise = Promise::new(&mut |resolve, reject| { - self.pending_queries - .borrow_mut() - .insert(query_id.clone(), PendingQuery { resolve, reject }); - }); + fn promote_without_lock(self: &Rc) { + self.on_lock_granted(); + } - post_query_request(&self.channel, &query_id, sql, params)?; + fn on_lock_granted(self: &Rc) { + *self.role.borrow_mut() = LeadershipRole::Leader; + self.mark_leader_known(self.worker_id.clone()); - let timeout_promise = schedule_timeout_promise( - Rc::clone(&self.pending_queries), - query_id.clone(), - self.follower_timeout_ms, - ); + let new_leader = ChannelMessage::NewLeader { + leader_id: self.worker_id.clone(), + }; + if let Err(err) = send_channel_message(&self.channel, &new_leader) { + let _ = send_worker_error_message(&err); + } + if let Err(err) = self.spawn_db_worker() { + let _ = send_worker_error_message(&js_value_to_string(&err)); + } + } - let result = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::race( - &js_sys::Array::of2(&promise, &timeout_promise), + fn spawn_db_worker(self: &Rc) -> Result<(), JsValue> { + let db_name_encoded = + serde_json::to_string(&self.db_name).unwrap_or_else(|_| "\"unknown\"".to_string()); + let body_val = Reflect::get( + &js_sys::global(), + &JsValue::from_str("__SQLITE_EMBEDDED_WORKER"), + ) + .map_err(|e| { + JsValue::from_str(&format!( + "Failed to read embedded worker source: {}", + js_value_to_string(&e) )) - .await; + })?; + let body = body_val + .as_string() + .ok_or_else(|| JsValue::from_str("Embedded worker source is missing"))?; + let preamble = format!( + "self.__SQLITE_DB_ONLY = true;\nself.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = {};\n", + db_name_encoded, + self.follower_timeout_ms, + ); + + let parts = js_sys::Array::new(); + parts.push(&JsValue::from_str(&preamble)); + parts.push(&JsValue::from_str(&body)); + let options = BlobPropertyBag::new(); + options.set_type("application/javascript"); + let blob = Blob::new_with_str_sequence_and_options(&parts, &options)?; + let url = Url::create_object_url_with_blob(&blob)?; + + let worker = Worker::new(&url)?; + Url::revoke_object_url(&url)?; + + let state = Rc::clone(self); + let handler = Closure::wrap(Box::new(move |event: MessageEvent| { + state.handle_db_worker_event(event); + }) as Box); + worker.set_onmessage(Some(handler.as_ref().unchecked_ref())); + handler.forget(); + + self.db_worker.borrow_mut().replace(worker); + Ok(()) + } - match result { - Ok(val) => val - .as_string() - .ok_or_else(|| "Invalid response".to_string()), - Err(e) => Err(js_value_to_string(&e)), + pub fn handle_db_worker_event(self: &Rc, event: MessageEvent) { + let data = event.data(); + match serde_wasm_bindgen::from_value::(data.clone()) { + Ok(MainThreadMessage::WorkerReady) => { + *self.db_worker_ready.borrow_mut() = true; + *self.leader_ready.borrow_mut() = true; + let ready = ChannelMessage::LeaderReady { + leader_id: self.worker_id.clone(), + }; + if let Err(err) = send_channel_message(&self.channel, &ready) { + let _ = send_worker_error_message(&err); + } + self.signal_ready_once(); + } + Ok(MainThreadMessage::QueryResult { + request_id, + result, + error, + }) => { + self.handle_db_query_result(request_id, result, error); + } + Err(_) => { + if let Some(err) = parse_worker_error_payload(&data) { + self.handle_db_worker_failure(err); + } } } } -} -fn handle_channel_message( - is_leader: &Rc>, - has_leader: &Rc>, - db: &Rc>>, - channel: &BroadcastChannel, - pending_queries: &Rc>>, - worker_id: &str, - msg: ChannelMessage, -) { - match msg { - ChannelMessage::QueryRequest { - query_id, + fn handle_db_worker_failure(&self, error: String) { + *self.db_worker_ready.borrow_mut() = false; + *self.leader_ready.borrow_mut() = false; + let _ = send_worker_error_message(&error); + let pending = self.db_pending.borrow_mut().drain().collect::>(); + for (_, origin) in pending { + self.fail_origin(origin, error.clone()); + } + } + + pub fn handle_main_message(self: &Rc, msg: WorkerMessage) { + match msg { + WorkerMessage::ExecuteQuery { + request_id, + sql, + params, + } => match *self.role.borrow() { + LeadershipRole::Leader => { + if !*self.db_worker_ready.borrow() { + let _ = send_query_result_to_main( + request_id, + Err(WORKER_ERROR_TYPE_INITIALIZATION_PENDING.to_string()), + ); + return; + } + self.forward_query_to_db(DbRequestOrigin::Local { request_id }, sql, params); + } + LeadershipRole::Follower => { + if !*self.leader_ready.borrow() { + let _ = send_query_result_to_main( + request_id, + Err(WORKER_ERROR_TYPE_INITIALIZATION_PENDING.to_string()), + ); + return; + } + let query_id = Uuid::new_v4().to_string(); + self.follower_pending + .borrow_mut() + .insert(query_id.clone(), request_id); + let pending = Rc::clone(&self.follower_pending); + let timeout = self.follower_timeout_ms; + let timeout_query_id = query_id.clone(); + spawn_local(async move { + sleep_ms(timeout.ceil() as i32).await; + if let Some(original) = pending.borrow_mut().remove(&timeout_query_id) { + let _ = send_query_result_to_main( + original, + Err("Query timeout".to_string()), + ); + } + }); + let request = ChannelMessage::QueryRequest { + query_id, + sql, + params, + }; + if let Err(err) = send_channel_message(&self.channel, &request) { + let _ = send_worker_error_message(&err); + } + } + }, + } + } + + fn handle_channel_message(self: &Rc, msg: ChannelMessage) { + match msg { + ChannelMessage::LeaderPing { requester_id: _ } => { + if matches!(*self.role.borrow(), LeadershipRole::Leader) { + let response = if *self.db_worker_ready.borrow() { + ChannelMessage::LeaderReady { + leader_id: self.worker_id.clone(), + } + } else { + ChannelMessage::NewLeader { + leader_id: self.worker_id.clone(), + } + }; + if let Err(err) = send_channel_message(&self.channel, &response) { + let _ = send_worker_error_message(&err); + } + } else if *self.leader_ready.borrow() { + let leader_id = self + .leader_id + .borrow() + .clone() + .unwrap_or_else(|| self.worker_id.clone()); + let response = ChannelMessage::LeaderReady { leader_id }; + let _ = send_channel_message(&self.channel, &response); + } + } + ChannelMessage::NewLeader { leader_id } => { + self.mark_leader_known(leader_id); + } + ChannelMessage::LeaderReady { leader_id } => { + self.mark_leader_known(leader_id); + *self.leader_ready.borrow_mut() = true; + self.signal_ready_once(); + } + ChannelMessage::QueryRequest { + query_id, + sql, + params, + } => { + if matches!(*self.role.borrow(), LeadershipRole::Leader) { + if !*self.db_worker_ready.borrow() { + let _ = send_channel_message( + &self.channel, + &ChannelMessage::QueryResponse { + query_id, + result: None, + error: Some(WORKER_ERROR_TYPE_INITIALIZATION_PENDING.to_string()), + }, + ); + return; + } + self.forward_query_to_db(DbRequestOrigin::Forwarded { query_id }, sql, params); + } + } + ChannelMessage::QueryResponse { + query_id, + result, + error, + } => { + if let Some(request_id) = self.follower_pending.borrow_mut().remove(&query_id) { + let outcome = match (result, error) { + (Some(res), _) => Ok(res), + (_, Some(err)) => Err(err), + _ => Err("Unknown query response".to_string()), + }; + let _ = send_query_result_to_main(request_id, outcome); + } + } + } + } + + fn forward_query_to_db( + self: &Rc, + origin: DbRequestOrigin, + sql: String, + params: Option>, + ) { + let worker = { + let borrow = self.db_worker.borrow(); + let Some(worker) = borrow.as_ref() else { + match origin { + DbRequestOrigin::Local { request_id } => { + let _ = send_query_result_to_main( + request_id, + Err(WORKER_ERROR_TYPE_INITIALIZATION_PENDING.to_string()), + ); + } + DbRequestOrigin::Forwarded { query_id } => { + let _ = send_channel_message( + &self.channel, + &ChannelMessage::QueryResponse { + query_id, + result: None, + error: Some(WORKER_ERROR_TYPE_INITIALIZATION_PENDING.to_string()), + }, + ); + } + } + return; + }; + worker.clone() + }; + + let db_request_id = { + let mut next = self.next_db_request_id.borrow_mut(); + let id = *next; + *next = next.wrapping_add(1).max(1); + id + }; + self.db_pending.borrow_mut().insert(db_request_id, origin); + + let msg = WorkerMessage::ExecuteQuery { + request_id: db_request_id, sql, params, - } => match *is_leader.borrow() { - LeadershipRole::Leader => { - let db = Rc::clone(db); - let channel = channel.clone(); - spawn_local(async move { - let result = exec_on_db(db, sql, params).await; - let response = build_query_response(query_id.clone(), result); - if let Err(err_msg) = send_channel_message(&channel, &response) { - let fallback = ChannelMessage::QueryResponse { - query_id: query_id.clone(), - result: None, - error: Some(err_msg), - }; - let _ = send_channel_message(&channel, &fallback); + }; + match serde_wasm_bindgen::to_value(&msg) { + Ok(val) => { + if let Err(err) = worker.post_message(&val) { + let _ = send_worker_error_message(&js_value_to_string(&err)); + if let Some(origin) = self.db_pending.borrow_mut().remove(&db_request_id) { + self.fail_origin( + origin, + "Failed to dispatch query to DB worker".to_string(), + ); } - }); + } } - LeadershipRole::Follower => {} - }, - ChannelMessage::QueryResponse { - query_id, - result, - error, - } => handle_query_response(pending_queries, query_id, result, error), - ChannelMessage::NewLeader { leader_id: _ } => { - let mut has_leader_ref = has_leader.borrow_mut(); - let previous_presence = *has_leader_ref; - *has_leader_ref = LeaderPresence::Known; - drop(has_leader_ref); - - match previous_presence { - LeaderPresence::Unknown => { - if let Err(err_msg) = send_worker_ready_message() { - let _ = send_worker_error_message(&err_msg); - } + Err(err) => { + let _ = send_worker_error_message(&format!("{err:?}")); + if let Some(origin) = self.db_pending.borrow_mut().remove(&db_request_id) { + self.fail_origin(origin, "Failed to serialize query".to_string()); } - LeaderPresence::Known => {} } } - ChannelMessage::LeaderPing { requester_id: _ } => match *is_leader.borrow() { - LeadershipRole::Leader => { - let response = ChannelMessage::NewLeader { - leader_id: worker_id.to_string(), - }; - if let Err(err_msg) = send_channel_message(channel, &response) { - let _ = send_worker_error_message(&err_msg); + } + + fn fail_origin(&self, origin: DbRequestOrigin, error: String) { + match origin { + DbRequestOrigin::Local { request_id } => { + let _ = send_query_result_to_main(request_id, Err(error)); + } + DbRequestOrigin::Forwarded { query_id } => { + let _ = send_channel_message( + &self.channel, + &ChannelMessage::QueryResponse { + query_id, + result: None, + error: Some(error), + }, + ); + } + } + } + + fn handle_db_query_result( + self: &Rc, + db_request_id: u32, + result: Option, + error: Option, + ) { + let Some(origin) = self.db_pending.borrow_mut().remove(&db_request_id) else { + return; + }; + let outcome = match (result, error) { + (Some(res), _) => Ok(res), + (_, Some(err)) => Err(error_payload_to_string(&err)), + _ => Err("Invalid response from DB worker".to_string()), + }; + match origin { + DbRequestOrigin::Local { request_id } => { + let _ = send_query_result_to_main(request_id, outcome); + } + DbRequestOrigin::Forwarded { query_id } => match outcome { + Ok(res) => { + let _ = send_channel_message( + &self.channel, + &ChannelMessage::QueryResponse { + query_id, + result: Some(res), + error: None, + }, + ); + } + Err(err) => { + let _ = send_channel_message( + &self.channel, + &ChannelMessage::QueryResponse { + query_id, + result: None, + error: Some(err), + }, + ); } + }, + } + } + + fn mark_leader_known(&self, leader_id: String) { + *self.leader_id.borrow_mut() = Some(leader_id); + } + + fn signal_ready_once(&self) { + if *self.ready_signaled.borrow() { + return; + } + *self.ready_signaled.borrow_mut() = true; + if let Err(err) = send_worker_ready_message() { + let _ = send_worker_error_message(&err); + } + } +} + +impl DbWorkerState { + pub fn new(config: WorkerConfig) -> Rc { + Rc::new(DbWorkerState { + db: Rc::new(RefCell::new(None)), + db_name: config.db_name, + }) + } + + pub fn start(self: &Rc) { + let state = Rc::clone(self); + spawn_local(async move { + match SQLiteDatabase::initialize_opfs(&state.db_name).await { + Ok(db) => { + *state.db.borrow_mut() = Some(db); + let _ = send_worker_ready_message(); + } + Err(err) => { + let _ = send_worker_error_message(&js_value_to_string(&err)); + } + } + }); + } + + pub fn handle_message(self: &Rc, msg: WorkerMessage) { + match msg { + WorkerMessage::ExecuteQuery { + request_id, + sql, + params, + } => { + let db = Rc::clone(&self.db); + spawn_local(async move { + let result = exec_on_db(db, sql, params).await; + match make_query_result_message(request_id, result) { + Ok(resp) => { + let _ = post_worker_message(&resp); + } + Err(err) => { + let _ = send_worker_error(err); + } + } + }); } - LeadershipRole::Follower => {} - }, + } } } -fn build_query_response(query_id: String, result: Result) -> ChannelMessage { - match result { - Ok(res) => ChannelMessage::QueryResponse { - query_id, - result: Some(res), - error: None, - }, - Err(err) => ChannelMessage::QueryResponse { - query_id, - result: None, - error: Some(err), - }, +fn send_channel_message( + channel: &BroadcastChannel, + message: &ChannelMessage, +) -> Result<(), String> { + let value = serde_wasm_bindgen::to_value(message) + .map_err(|err| format!("Failed to serialize channel message: {err:?}"))?; + channel.post_message(&value).map_err(|err| { + format!( + "Failed to post channel message: {}", + js_value_to_string(&err) + ) + }) +} + +fn error_payload_to_string(payload: &WorkerErrorPayload) -> String { + payload + .message + .clone() + .unwrap_or_else(|| payload.error_type.clone()) +} + +pub fn send_worker_ready_message() -> Result<(), String> { + let message = js_sys::Object::new(); + set_js_property(&message, "type", &JsValue::from_str("worker-ready")) + .map_err(|err| js_value_to_string(&err))?; + post_worker_message(&message) +} + +pub fn send_worker_error_message(error: &str) -> Result<(), String> { + let message = js_sys::Object::new(); + set_js_property(&message, "type", &JsValue::from_str("worker-error")) + .map_err(|err| js_value_to_string(&err))?; + set_js_property(&message, "error", &JsValue::from_str(error)) + .map_err(|err| js_value_to_string(&err))?; + post_worker_message(&message) +} + +pub fn post_worker_message(obj: &js_sys::Object) -> Result<(), String> { + let global = js_sys::global(); + let scope: DedicatedWorkerGlobalScope = global + .dyn_into() + .map_err(|_| "Failed to access worker scope".to_string())?; + scope + .post_message(obj.as_ref()) + .map_err(|err| js_value_to_string(&err)) +} + +pub fn send_worker_error(err: JsValue) -> Result<(), JsValue> { + let message = js_value_to_string(&err); + send_worker_error_message(&message).map_err(|post_err| { + JsValue::from_str(&format!( + "Failed to deliver worker error '{message}': {}", + post_err + )) + }) +} + +fn parse_worker_error_payload(data: &JsValue) -> Option { + let msg_type = Reflect::get(data, &JsValue::from_str("type")) + .ok() + .and_then(|val| val.as_string())?; + if msg_type != "worker-error" { + return None; } + Reflect::get(data, &JsValue::from_str("error")) + .ok() + .and_then(|val| { + if val.is_undefined() || val.is_null() { + None + } else { + Some(js_value_to_string(&val)) + } + }) + .or_else(|| Some("Unknown worker error".to_string())) +} + +fn make_structured_error(err: &str) -> Result { + let error_object = js_sys::Object::new(); + let error_type = if err == WORKER_ERROR_TYPE_INITIALIZATION_PENDING { + WORKER_ERROR_TYPE_INITIALIZATION_PENDING + } else { + crate::messages::WORKER_ERROR_TYPE_GENERIC + }; + set_js_property( + error_object.as_ref(), + "type", + &JsValue::from_str(error_type), + )?; + set_js_property(error_object.as_ref(), "message", &JsValue::from_str(err))?; + Ok(error_object.into()) } -fn handle_query_response( - pending_queries: &Rc>>, - query_id: String, - result: Option, - error: Option, -) { - if let Some(pending) = pending_queries.borrow_mut().remove(&query_id) { - if let Some(err) = error { - let _ = pending - .reject - .call1(&JsValue::NULL, &JsValue::from_str(&err)); - } else if let Some(res) = result { - let _ = pending - .resolve - .call1(&JsValue::NULL, &JsValue::from_str(&res)); +pub fn make_query_result_message( + request_id: u32, + result: Result, +) -> Result { + let response = js_sys::Object::new(); + set_js_property(&response, "type", &JsValue::from_str("query-result"))?; + set_js_property( + &response, + "requestId", + &JsValue::from_f64(request_id as f64), + )?; + match result { + Ok(res) => { + set_js_property(&response, "result", &JsValue::from_str(&res))?; + set_js_property(&response, "error", &JsValue::NULL)?; + } + Err(err) => { + set_js_property(&response, "result", &JsValue::NULL)?; + let error_value = make_structured_error(&err)?; + set_js_property(&response, "error", &error_value)?; } } + Ok(response) +} + +pub fn send_query_result_to_main( + request_id: u32, + result: Result, +) -> Result<(), JsValue> { + let message = make_query_result_message(request_id, result)?; + post_worker_message(&message).map_err(|err| JsValue::from_str(&err)) } -async fn sleep_ms(ms: i32) { +async fn exec_on_db( + db: Rc>>, + sql: String, + params: Option>, +) -> Result { + let db_opt = db.borrow_mut().take(); + let result = match db_opt { + Some(mut database) => { + let result = match params { + Some(p) => database.exec_with_params(&sql, p).await, + None => database.exec(&sql).await, + }; + *db.borrow_mut() = Some(database); + result + } + None => Err(WORKER_ERROR_TYPE_INITIALIZATION_PENDING.to_string()), + }; + result +} + +pub async fn sleep_ms(ms: i32) { let promise = js_sys::Promise::new(&mut |resolve, _| { let resolve_for_timeout = resolve.clone(); let closure = Closure::once(move || { @@ -474,7 +821,6 @@ async fn sleep_ms(ms: i32) { }); if timeout_result.is_err() { - // As a best-effort fallback, resolve immediately. let _ = resolve.call0(&JsValue::NULL); } @@ -482,336 +828,3 @@ async fn sleep_ms(ms: i32) { }); let _ = JsFuture::from(promise).await; } - -async fn exec_on_db( - db: Rc>>, - sql: String, - params: Option>, -) -> Result { - let db_opt = db.borrow_mut().take(); - let result = match db_opt { - Some(mut database) => { - let result = match params { - Some(p) => database.exec_with_params(&sql, p).await, - None => database.exec(&sql).await, - }; - *db.borrow_mut() = Some(database); - result - } - None => Err("Database not initialized".to_string()), - }; - result -} - -fn post_query_request( - channel: &BroadcastChannel, - query_id: &str, - sql: String, - params: Option>, -) -> Result<(), String> { - let msg = ChannelMessage::QueryRequest { - query_id: query_id.to_string(), - sql, - params, - }; - let msg_js = serde_wasm_bindgen::to_value(&msg) - .map_err(|e| format!("Failed to serialize query request: {e:?}"))?; - channel - .post_message(&msg_js) - .map_err(|e| format!("Failed to post query request: {e:?}")) -} - -fn schedule_timeout_promise( - pending_queries: Rc>>, - query_id: String, - ms: f64, -) -> Promise { - Promise::new(&mut move |_, reject| { - let query_id = query_id.clone(); - let pending_queries = Rc::clone(&pending_queries); - let callback = Closure::once(move || { - if pending_queries.borrow_mut().remove(&query_id).is_some() { - let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("Query timeout")); - } - }); - - let global = js_sys::global(); - if let Ok(set_timeout_value) = reflect_get(&global, "setTimeout") { - if let Some(set_timeout_fn) = set_timeout_value.dyn_ref::() { - let _ = set_timeout_fn.call2( - &JsValue::NULL, - callback.as_ref().unchecked_ref(), - &JsValue::from_f64(ms), - ); - } - } - callback.forget(); - }) -} - -#[cfg(all(test, target_family = "wasm"))] -mod tests { - use super::*; - use js_sys::Function; - use wasm_bindgen_test::*; - - wasm_bindgen_test_configure!(run_in_browser); - - #[wasm_bindgen_test] - fn test_worker_state_creation_and_uniqueness() { - let results: Vec<_> = (0..5).map(|_| WorkerState::new()).collect(); - let workers: Vec<_> = results.into_iter().filter_map(Result::ok).collect(); - - assert!(!workers.is_empty(), "Should create at least one worker"); - - let state = &workers[0]; - assert!(!state.worker_id.is_empty(), "Worker ID should not be empty"); - assert!( - state.worker_id.contains('-'), - "Worker ID should be valid UUID format" - ); - assert_eq!( - *state.is_leader.borrow(), - LeadershipRole::Follower, - "New workers should not start as leader" - ); - assert!( - state.db.borrow().is_none(), - "Database should be uninitialized" - ); - assert!( - state.pending_queries.borrow().is_empty(), - "Should have no pending queries" - ); - - if workers.len() >= 2 { - let mut ids = std::collections::HashSet::new(); - for worker in &workers { - assert!( - ids.insert(worker.worker_id.clone()), - "Worker ID {} should be unique", - worker.worker_id - ); - assert_eq!(worker.worker_id.len(), 36, "UUID should be 36 characters"); - assert_eq!( - worker.worker_id.chars().filter(|&c| c == '-').count(), - 4, - "UUID should have 4 dashes" - ); - } - assert_eq!(ids.len(), workers.len(), "All worker IDs should be unique"); - } - } - - #[wasm_bindgen_test] - fn test_leadership_state_management() { - if let Ok(state) = WorkerState::new() { - assert_eq!( - *state.is_leader.borrow(), - LeadershipRole::Follower, - "Should start as follower" - ); - - *state.is_leader.borrow_mut() = LeadershipRole::Leader; - assert_eq!( - *state.is_leader.borrow(), - LeadershipRole::Leader, - "Should become leader" - ); - - *state.is_leader.borrow_mut() = LeadershipRole::Follower; - assert_eq!( - *state.is_leader.borrow(), - LeadershipRole::Follower, - "Should become follower again" - ); - } - } - - #[wasm_bindgen_test] - fn test_pending_queries_management() { - if let Ok(state) = WorkerState::new() { - let pending_queries = Rc::clone(&state.pending_queries); - - assert_eq!(pending_queries.borrow().len(), 0); - - let test_queries = vec!["query-a", "query-b", "query-c", "query-d", "query-e"]; - { - let mut queries = pending_queries.borrow_mut(); - for query_id in &test_queries { - let resolve = - Function::new_no_args(&format!("return 'resolved-{}';", query_id)); - let reject = Function::new_no_args(&format!("return 'rejected-{}';", query_id)); - queries.insert(query_id.to_string(), PendingQuery { resolve, reject }); - } - } - - assert_eq!(pending_queries.borrow().len(), test_queries.len()); - for query_id in &test_queries { - assert!(pending_queries.borrow().contains_key(*query_id)); - } - - for (i, query_id) in test_queries.iter().enumerate() { - if i % 2 == 0 { - let removed = pending_queries.borrow_mut().remove(*query_id); - assert!(removed.is_some(), "Should remove query {}", query_id); - } - } - - let remaining_count = test_queries.len() - test_queries.len().div_ceil(2); - assert_eq!(pending_queries.borrow().len(), remaining_count); - - pending_queries.borrow_mut().clear(); - assert_eq!(pending_queries.borrow().len(), 0); - - { - let mut queries = pending_queries.borrow_mut(); - let resolve = Function::new_no_args("return 'post-cleanup';"); - let reject = Function::new_no_args("return 'rejected';"); - queries.insert( - "post-cleanup-test".to_string(), - PendingQuery { resolve, reject }, - ); - } - assert_eq!(pending_queries.borrow().len(), 1); - assert!(pending_queries.borrow().contains_key("post-cleanup-test")); - } - } - - #[wasm_bindgen_test] - fn test_message_deserialization_error_handling() { - let invalid_json = JsValue::from_str("invalid json"); - let result = serde_wasm_bindgen::from_value::(invalid_json); - assert!(result.is_err(), "Should fail to deserialize invalid JSON"); - } - - #[wasm_bindgen_test] - async fn test_execute_query_leader_vs_follower_paths() { - if let Ok(leader_state) = WorkerState::new() { - *leader_state.is_leader.borrow_mut() = LeadershipRole::Leader; - - let test_queries = vec![ - "", - "SELECT 1", - "INSERT INTO test VALUES (1, 'hello')", - "SELECT 'test with spaces and symbols: !@#$%^&*()'", - "SELECT 'Hello δΈ–η•Œ 🌍'", - ]; - - for query in test_queries { - let result = leader_state.execute_query(query.to_string(), None).await; - match result { - Err(msg) => assert_eq!( - msg, "Database not initialized", - "Leader should get DB init error for query: {}", - query - ), - Ok(_) => panic!( - "Expected database not initialized error for query: {}", - query - ), - } - } - } - - if let Ok(follower_state) = WorkerState::new() { - assert_eq!( - *follower_state.is_leader.borrow(), - LeadershipRole::Follower, - "Should start as follower" - ); - - let result = follower_state - .execute_query("SELECT 1".to_string(), None) - .await; - match result { - Err(msg) => assert_eq!( - msg, WORKER_ERROR_TYPE_INITIALIZATION_PENDING, - "Follower should reject while leader is pending" - ), - Ok(_) => panic!("Expected initialization error for follower"), - } - } - } - - #[wasm_bindgen_test] - fn test_setup_channel_listener() { - if let Ok(state) = WorkerState::new() { - assert!( - state.setup_channel_listener().is_ok(), - "setup_channel_listener should succeed" - ); - } - } - - #[wasm_bindgen_test] - async fn test_attempt_leadership_behavior() { - if let Ok(state) = WorkerState::new() { - assert_eq!( - *state.is_leader.borrow(), - LeadershipRole::Follower, - "Should start as follower" - ); - assert!( - state.db.borrow().is_none(), - "Database should be uninitialized" - ); - - let _ = state.attempt_leadership().await; - } - - let workers: Vec<_> = (0..3).filter_map(|_| WorkerState::new().ok()).collect(); - if workers.len() >= 2 { - for worker in &workers { - assert_eq!( - *worker.is_leader.borrow(), - LeadershipRole::Follower, - "All should start as followers" - ); - } - - for worker in &workers { - let _ = worker.attempt_leadership().await; - } - } - } - - #[wasm_bindgen_test] - fn test_worker_state_rc_shared_references() { - if let Ok(state) = WorkerState::new() { - let is_leader_clone = Rc::clone(&state.is_leader); - let pending_clone = Rc::clone(&state.pending_queries); - - assert_eq!(*state.is_leader.borrow(), *is_leader_clone.borrow()); - assert_eq!( - state.pending_queries.borrow().len(), - pending_clone.borrow().len() - ); - - *state.is_leader.borrow_mut() = LeadershipRole::Leader; - assert_eq!( - *is_leader_clone.borrow(), - LeadershipRole::Leader, - "Changes should be visible through cloned Rc" - ); - - { - let resolve = Function::new_no_args("return 'resolved';"); - let reject = Function::new_no_args("return 'rejected';"); - pending_clone - .borrow_mut() - .insert("test-ref".to_string(), PendingQuery { resolve, reject }); - } - assert_eq!( - state.pending_queries.borrow().len(), - 1, - "Should see changes through original Rc" - ); - } - } - - #[wasm_bindgen_test(async)] - async fn test_sleep_ms_completes() { - sleep_ms(0).await; - } -} diff --git a/packages/sqlite-web-core/src/messages.rs b/packages/sqlite-web-core/src/messages.rs index b094468..cb2f249 100644 --- a/packages/sqlite-web-core/src/messages.rs +++ b/packages/sqlite-web-core/src/messages.rs @@ -22,6 +22,11 @@ pub enum ChannelMessage { #[serde(rename = "leaderId")] leader_id: String, }, + #[serde(rename = "leader-ready")] + LeaderReady { + #[serde(rename = "leaderId")] + leader_id: String, + }, #[serde(rename = "query-request")] QueryRequest { #[serde(rename = "queryId")] @@ -214,6 +219,13 @@ mod tests { assert!(json.contains("\"leaderId\":\"\"")); }); + let leader_ready = ChannelMessage::LeaderReady { + leader_id: "leader-1".to_string(), + }; + assert_serialization_roundtrip(leader_ready, "leader-ready", |json| { + assert!(json.contains("\"leaderId\":\"leader-1\"")); + }); + let empty_sql = ChannelMessage::QueryRequest { query_id: "test".to_string(), sql: String::new(), diff --git a/packages/sqlite-web-core/src/worker.rs b/packages/sqlite-web-core/src/worker.rs index 4e53461..b9e554d 100644 --- a/packages/sqlite-web-core/src/worker.rs +++ b/packages/sqlite-web-core/src/worker.rs @@ -1,425 +1,117 @@ -// worker.rs - This module runs in the worker context use std::cell::RefCell; use std::rc::Rc; +use wasm_bindgen::closure::Closure; use wasm_bindgen::prelude::*; -use wasm_bindgen::throw_val; use wasm_bindgen::JsCast; -use wasm_bindgen_futures::spawn_local; use web_sys::{DedicatedWorkerGlobalScope, MessageEvent}; -use crate::coordination::WorkerState; -use crate::messages::{ - WorkerMessage, WORKER_ERROR_TYPE_GENERIC, WORKER_ERROR_TYPE_INITIALIZATION_PENDING, +use crate::coordination::{ + send_worker_error, worker_config_from_global, CoordinatorState, DbWorkerState, WorkerConfig, }; -use crate::util::{js_value_to_string, set_js_property}; +use crate::messages::WorkerMessage; -// Global state -thread_local! { - static WORKER_STATE: RefCell>> = const { RefCell::new(None) }; +enum WorkerRuntime { + Coordinator(Rc), + DbOnly(Rc), } -fn send_worker_error_message(message: &str) -> Result<(), JsValue> { - let error_object = js_sys::Object::new(); - set_js_property( - error_object.as_ref(), - "type", - &JsValue::from_str("worker-error"), - )?; - set_js_property(error_object.as_ref(), "error", &JsValue::from_str(message))?; - let global = js_sys::global(); - let worker_scope: DedicatedWorkerGlobalScope = global.unchecked_into(); - worker_scope.post_message(error_object.as_ref()) +thread_local! { + static RUNTIME: RefCell> = const { RefCell::new(None) }; } -fn send_worker_error(err: JsValue) -> Result<(), JsValue> { - let message = js_value_to_string(&err); - send_worker_error_message(&message).map_err(|post_err| { - JsValue::from_str(&format!( - "Failed to deliver worker error '{message}': {}", - js_value_to_string(&post_err) - )) - }) +fn is_db_only_mode() -> bool { + let global = js_sys::global(); + js_sys::Reflect::get(&global, &JsValue::from_str("__SQLITE_DB_ONLY")) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(false) } -fn post_message(obj: &js_sys::Object) -> Result<(), JsValue> { +fn install_main_thread_handler() { let global = js_sys::global(); let worker_scope: DedicatedWorkerGlobalScope = global.unchecked_into(); - worker_scope.post_message(obj.as_ref()) -} + let onmessage = Closure::wrap(Box::new(move |event: MessageEvent| { + match serde_wasm_bindgen::from_value::(event.data()) { + Ok(msg) => { + RUNTIME.with(|runtime| { + if let Some(inner) = runtime.borrow().as_ref() { + match inner { + WorkerRuntime::Coordinator(state) => state.handle_main_message(msg), + WorkerRuntime::DbOnly(db) => db.handle_message(msg), + } + } + }); + } + Err(err) => { + let _ = send_worker_error(JsValue::from_str(&format!( + "Invalid worker message: {err:?}" + ))); + } + } + }) as Box); -fn make_structured_error(err: &str) -> Result { - let error_object = js_sys::Object::new(); - let error_type = if err == WORKER_ERROR_TYPE_INITIALIZATION_PENDING { - WORKER_ERROR_TYPE_INITIALIZATION_PENDING - } else { - WORKER_ERROR_TYPE_GENERIC - }; - set_js_property( - error_object.as_ref(), - "type", - &JsValue::from_str(error_type), - )?; - set_js_property(error_object.as_ref(), "message", &JsValue::from_str(err))?; - Ok(error_object.into()) + worker_scope.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); + onmessage.forget(); } -fn make_query_result_message( - request_id: u32, - result: Result, -) -> Result { - let response = js_sys::Object::new(); - set_js_property(&response, "type", &JsValue::from_str("query-result"))?; - set_js_property( - &response, - "requestId", - &JsValue::from_f64(request_id as f64), - )?; - match result { - Ok(res) => { - set_js_property(&response, "result", &JsValue::from_str(&res))?; - set_js_property(&response, "error", &JsValue::NULL)?; - } - Err(err) => { - set_js_property(&response, "result", &JsValue::NULL)?; - let error_value = make_structured_error(&err)?; - set_js_property(&response, "error", &error_value)?; - } - } - Ok(response) -} +fn start_coordinator_runtime(config: WorkerConfig) -> Result<(), JsValue> { + let state = CoordinatorState::new(config)?; + state.setup_channel_listener()?; + state.start_leader_probe(); + state.try_become_leader(); -fn handle_incoming_value(data: JsValue) { - let make_error_response = || { - let o = js_sys::Object::new(); - let _ = set_js_property(&o, "type", &JsValue::from_str("query-result")); - let _ = set_js_property(&o, "result", &JsValue::NULL); - let _ = set_js_property(&o, "error", &JsValue::from_str("Failed to build response")); - o - }; + RUNTIME.with(|runtime| { + *runtime.borrow_mut() = Some(WorkerRuntime::Coordinator(Rc::clone(&state))); + }); - let send_response = |response: &js_sys::Object| { - post_message(response) - .or_else(|err| send_worker_error(err).map_err(throw_val)) - .ok(); - }; - match serde_wasm_bindgen::from_value::(data) { - Ok(WorkerMessage::ExecuteQuery { - request_id, - sql, - params, - }) => { - WORKER_STATE.with(|s| { - let Some(state) = s.borrow().as_ref().map(Rc::clone) else { - return; - }; - spawn_local(async move { - let result = state.execute_query(sql, params).await; - let response = - make_query_result_message(request_id, result).unwrap_or_else(|err| { - let _ = send_worker_error(err); - make_error_response() - }); - send_response(&response); - }); - }); - } - Err(err) => { - let error_text = format!("Invalid worker message: {err:?}"); - // No requestId available here; send error without it - let response = (|| { - let o = js_sys::Object::new(); - set_js_property(&o, "type", &JsValue::from_str("query-result"))?; - set_js_property(&o, "result", &JsValue::NULL)?; - set_js_property(&o, "error", &JsValue::from_str(&error_text))?; - Ok(o) - })() - .unwrap_or_else(|e| { - let _ = send_worker_error(e); - make_error_response() - }); - send_response(&response); - } - } + install_main_thread_handler(); + Ok(()) +} + +fn start_db_only_runtime(config: WorkerConfig) -> Result<(), JsValue> { + let state = DbWorkerState::new(config); + state.start(); + RUNTIME.with(|runtime| { + *runtime.borrow_mut() = Some(WorkerRuntime::DbOnly(Rc::clone(&state))); + }); + install_main_thread_handler(); + Ok(()) } /// Entry point for the worker - called from the blob pub fn main() -> Result<(), JsValue> { console_error_panic_hook::set_once(); + let config = worker_config_from_global()?; - let state = Rc::new(WorkerState::new().inspect_err(|err| { - let _ = send_worker_error(err.clone()); - })?); - - state.setup_channel_listener().inspect_err(|err| { - let _ = send_worker_error(err.clone()); - })?; - - let state_clone = Rc::clone(&state); - spawn_local(async move { - if let Err(err) = state_clone.attempt_leadership().await { - if let Err(send_err) = send_worker_error(err) { - throw_val(send_err); - } - } - }); - - WORKER_STATE.with(|s| { - *s.borrow_mut() = Some(Rc::clone(&state)); - }); - state.start_leader_probe(); - - // Setup message handler from main thread - let global = js_sys::global(); - let worker_scope: DedicatedWorkerGlobalScope = global.unchecked_into(); - - let onmessage = Closure::wrap(Box::new(move |event: MessageEvent| { - let data = event.data(); - handle_incoming_value(data); - }) as Box); - - worker_scope.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); - onmessage.forget(); - - Ok(()) + if is_db_only_mode() { + start_db_only_runtime(config) + } else { + start_coordinator_runtime(config) + } } #[cfg(all(test, target_family = "wasm"))] mod tests { use super::*; - use crate::coordination::LeadershipRole; - use js_sys::{Object, Reflect}; - use std::rc::Rc; + use js_sys::Reflect; use wasm_bindgen_test::*; - use web_sys::MessageEvent; wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen_test] - fn test_worker_state_creation() { - let state = WorkerState::new(); - assert!(state.is_ok()); - let worker_state = state.unwrap(); - assert!(!worker_state.worker_id.is_empty()); - } - - #[wasm_bindgen_test] - fn test_main_function_initialization() { - let result = main(); - assert!(result.is_ok()); - } - - #[wasm_bindgen_test] - fn test_global_worker_scope_access() { - let global = js_sys::global(); - assert!(!global.is_undefined()); - assert!(!global.is_null()); - } - - #[wasm_bindgen_test] - fn test_message_structure_validation() { - let valid_msg = Object::new(); - Reflect::set( - &valid_msg, - &JsValue::from_str("type"), - &JsValue::from_str("execute-query"), - ) - .unwrap(); - Reflect::set( - &valid_msg, - &JsValue::from_str("sql"), - &JsValue::from_str("SELECT 1"), - ) - .unwrap(); - - let msg_type = Reflect::get(&valid_msg, &JsValue::from_str("type")).unwrap(); - let sql = Reflect::get(&valid_msg, &JsValue::from_str("sql")).unwrap(); - - assert_eq!(msg_type.as_string().unwrap(), "execute-query"); - assert_eq!(sql.as_string().unwrap(), "SELECT 1"); - - let invalid_msg = Object::new(); - Reflect::set( - &invalid_msg, - &JsValue::from_str("type"), - &JsValue::from_str("execute-query"), - ) - .unwrap(); - - let sql_result = Reflect::get(&invalid_msg, &JsValue::from_str("sql")); - assert!(sql_result.is_ok()); - assert!(sql_result.unwrap().is_undefined()); - - let empty_sql_msg = Object::new(); - Reflect::set( - &empty_sql_msg, - &JsValue::from_str("type"), - &JsValue::from_str("execute-query"), - ) - .unwrap(); - Reflect::set( - &empty_sql_msg, - &JsValue::from_str("sql"), - &JsValue::from_str(""), - ) - .unwrap(); - - let empty_sql = Reflect::get(&empty_sql_msg, &JsValue::from_str("sql")).unwrap(); - assert_eq!(empty_sql.as_string().unwrap(), ""); + fn db_only_mode_defaults_to_false() { + let _ = Reflect::delete_property(&js_sys::global(), &JsValue::from_str("__SQLITE_DB_ONLY")); + assert!(!is_db_only_mode()); } #[wasm_bindgen_test] - fn test_query_response_structure() { - let success_response = Object::new(); - Reflect::set( - &success_response, - &JsValue::from_str("type"), - &JsValue::from_str("query-result"), - ) - .unwrap(); - Reflect::set( - &success_response, - &JsValue::from_str("result"), - &JsValue::from_str("test_result"), - ) - .unwrap(); + fn db_only_mode_reads_flag() { Reflect::set( - &success_response, - &JsValue::from_str("error"), - &JsValue::NULL, + &js_sys::global(), + &JsValue::from_str("__SQLITE_DB_ONLY"), + &JsValue::TRUE, ) .unwrap(); - - let response_type = Reflect::get(&success_response, &JsValue::from_str("type")).unwrap(); - let result = Reflect::get(&success_response, &JsValue::from_str("result")).unwrap(); - let error = Reflect::get(&success_response, &JsValue::from_str("error")).unwrap(); - - assert_eq!(response_type.as_string().unwrap(), "query-result"); - assert_eq!(result.as_string().unwrap(), "test_result"); - assert!(error.is_null()); - - let error_response = Object::new(); - Reflect::set( - &error_response, - &JsValue::from_str("type"), - &JsValue::from_str("query-result"), - ) - .unwrap(); - Reflect::set( - &error_response, - &JsValue::from_str("result"), - &JsValue::NULL, - ) - .unwrap(); - Reflect::set( - &error_response, - &JsValue::from_str("error"), - &JsValue::from_str("test_error"), - ) - .unwrap(); - - let error_type = Reflect::get(&error_response, &JsValue::from_str("type")).unwrap(); - let error_result = Reflect::get(&error_response, &JsValue::from_str("result")).unwrap(); - let error_msg = Reflect::get(&error_response, &JsValue::from_str("error")).unwrap(); - - assert_eq!(error_type.as_string().unwrap(), "query-result"); - assert!(error_result.is_null()); - assert_eq!(error_msg.as_string().unwrap(), "test_error"); - } - - #[wasm_bindgen_test] - fn test_worker_state_async_query_setup() { - if let Ok(state) = WorkerState::new() { - let state_rc = Rc::new(state); - - assert_eq!(*state_rc.is_leader.borrow(), LeadershipRole::Follower); - assert!(state_rc.db.borrow().is_none()); - assert!(state_rc.pending_queries.borrow().is_empty()); - } - } - - #[wasm_bindgen_test] - fn test_worker_leadership_state() { - if let Ok(state) = WorkerState::new() { - let state_rc = Rc::new(state); - - assert_eq!(*state_rc.is_leader.borrow(), LeadershipRole::Follower); - - *state_rc.is_leader.borrow_mut() = LeadershipRole::Leader; - assert_eq!(*state_rc.is_leader.borrow(), LeadershipRole::Leader); - } - } - - #[wasm_bindgen_test] - fn test_worker_state_reference_counting() { - let state = WorkerState::new().unwrap(); - let state_rc = Rc::new(state); - let cloned_state = Rc::clone(&state_rc); - assert_eq!(Rc::strong_count(&state_rc), 2); - drop(cloned_state); - assert_eq!(Rc::strong_count(&state_rc), 1); - } - - #[wasm_bindgen_test] - fn test_error_state_validation() { - if let Ok(state) = WorkerState::new() { - let state_rc = Rc::new(state); - - *state_rc.is_leader.borrow_mut() = LeadershipRole::Leader; - assert_eq!(*state_rc.is_leader.borrow(), LeadershipRole::Leader); - assert!(state_rc.db.borrow().is_none()); - } - } - - #[wasm_bindgen_test] - fn test_message_event_handling() { - WORKER_STATE.with(|s| { - if let Ok(state) = WorkerState::new() { - *s.borrow_mut() = Some(Rc::new(state)); - - let msg = Object::new(); - Reflect::set( - &msg, - &JsValue::from_str("type"), - &JsValue::from_str("execute-query"), - ) - .unwrap(); - Reflect::set( - &msg, - &JsValue::from_str("sql"), - &JsValue::from_str("SELECT 1"), - ) - .unwrap(); - - let _event = MessageEvent::new("message").unwrap(); - - let global = js_sys::global(); - let _constructor = - Reflect::get(&global, &JsValue::from_str("MessageEvent")).unwrap(); - let event_init = Object::new(); - Reflect::set(&event_init, &JsValue::from_str("data"), &msg).unwrap(); - - if let Some(worker_state) = s.borrow().as_ref() { - assert!(!worker_state.worker_id.is_empty()); - } - } - }); - } - - #[wasm_bindgen_test] - fn test_worker_coordination_state_setup() { - if let Ok(leader_state) = WorkerState::new() { - if let Ok(follower_state) = WorkerState::new() { - let leader_rc = Rc::new(leader_state); - let follower_rc = Rc::new(follower_state); - - *leader_rc.is_leader.borrow_mut() = LeadershipRole::Leader; - assert_eq!(*follower_rc.is_leader.borrow(), LeadershipRole::Follower); - - assert!(leader_rc.setup_channel_listener().is_ok()); - assert!(follower_rc.setup_channel_listener().is_ok()); - - assert_ne!(leader_rc.worker_id, follower_rc.worker_id); - } - } + assert!(is_db_only_mode()); } } diff --git a/packages/sqlite-web/src/worker_template.rs b/packages/sqlite-web/src/worker_template.rs index 457fe93..f41a07e 100644 --- a/packages/sqlite-web/src/worker_template.rs +++ b/packages/sqlite-web/src/worker_template.rs @@ -4,9 +4,11 @@ pub fn generate_self_contained_worker(db_name: &str) -> String { // Safely JSON-encode the db name for JS embedding let encoded = serde_json::to_string(db_name).unwrap_or_else(|_| "\"unknown\"".to_string()); + let embedded_body = serde_json::to_string(include_str!("embedded_worker.js")) + .unwrap_or_else(|_| "\"\"".to_string()); let prefix = format!( - "self.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = 5000.0;\n", - encoded + "self.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = 5000.0;\nself.__SQLITE_EMBEDDED_WORKER = {};\n", + encoded, embedded_body ); // Use the bundled worker template with embedded WASM let body = include_str!("embedded_worker.js"); @@ -31,6 +33,10 @@ mod tests { output.contains("self.__SQLITE_FOLLOWER_TIMEOUT_MS = 5000.0;"), "timeout constant should be injected" ); + assert!( + output.contains("self.__SQLITE_EMBEDDED_WORKER = "), + "embedded worker body should be stored on the global" + ); } #[wasm_bindgen_test] From d020ef974d91140a437d69256f6426188c90ef50 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 10:18:10 +0300 Subject: [PATCH 02/10] update --- pkg/package.json | 2 +- svelte-test/package-lock.json | 354 +++++++++++++----------- svelte-test/package.json | 2 +- svelte-test/src/routes/sql/+page.svelte | 18 +- 4 files changed, 195 insertions(+), 181 deletions(-) diff --git a/pkg/package.json b/pkg/package.json index e58d348..8032b47 100644 --- a/pkg/package.json +++ b/pkg/package.json @@ -12,4 +12,4 @@ "sideEffects": [ "./snippets/*" ] -} +} \ No newline at end of file diff --git a/svelte-test/package-lock.json b/svelte-test/package-lock.json index 13fbac8..95a5a67 100644 --- a/svelte-test/package-lock.json +++ b/svelte-test/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@rainlanguage/float": "^0.0.0-alpha.22", - "@rainlanguage/sqlite-web": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.7.tgz" + "@rainlanguage/sqlite-web": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.8.tgz" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.0.0", @@ -671,13 +671,13 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.20", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.20.tgz", - "integrity": "sha512-HDGiWh2tyRZa0M1ZnEIUCQro25gW/mN8ODByicQrbR1yHx4hT+IOpozCMi5TgBtUdklLwRI2mv14eNpftDluEw==", + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.1", + "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "engines": { @@ -693,9 +693,9 @@ } }, "node_modules/@inquirer/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.1.tgz", - "integrity": "sha512-hzGKIkfomGFPgxKmnKEKeA+uCYBqC+TKtRx5LgyHRCrF6S2MliwRIjp3sUaWwVzMp7ZXVs8elB0Tfe682Rpg4w==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dev": true, "license": "MIT", "dependencies": { @@ -703,7 +703,7 @@ "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", - "mute-stream": "^3.0.0", + "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" @@ -900,9 +900,9 @@ "license": "MIT" }, "node_modules/@rainlanguage/float": { - "version": "0.0.0-alpha.39", - "resolved": "https://registry.npmjs.org/@rainlanguage/float/-/float-0.0.0-alpha.39.tgz", - "integrity": "sha512-K+gOwQwCe+QlHq+6eIjBgsxwnV8U2kBOAtlJNtVtL0nl9MljZIQX+Pl8jhqeVoIsMNNgjTKhL4/j9p66zUM+nA==", + "version": "0.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@rainlanguage/float/-/float-0.0.0-alpha.40.tgz", + "integrity": "sha512-YChHC8pvTQy9gx8xbavP31C+iZLo3w6M95kimrsGOdeLwYmoLdQZFjFEIoYZ6jJlHrNgOJKpxErs1w5DXzACmg==", "license": "LicenseRef-DCL-1.0", "dependencies": { "buffer": "6.0.3" @@ -912,14 +912,14 @@ } }, "node_modules/@rainlanguage/sqlite-web": { - "version": "0.0.1-alpha.7", - "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.7.tgz", - "integrity": "sha512-yCPVkRqqmpxuYrTRu4ch4BffBM8RhTMqNuyRH9/v8kKT+UXgooTKjPwzpbzIM9+5fSe54B4uOEayUOI1O1qxww==" + "version": "0.0.1-alpha.8", + "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.8.tgz", + "integrity": "sha512-n3l2AMSlxyeWle1+S2bl4OqPc206mW1o/TstFEfJex2F4D2uqOSBKzb7s2DbNtVF134hARp8VpJi+GxQN5t7LQ==" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", "cpu": [ "arm" ], @@ -931,9 +931,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", "cpu": [ "arm64" ], @@ -945,9 +945,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "cpu": [ "arm64" ], @@ -959,9 +959,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "cpu": [ "x64" ], @@ -973,9 +973,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", "cpu": [ "arm64" ], @@ -987,9 +987,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "cpu": [ "x64" ], @@ -1001,9 +1001,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", "cpu": [ "arm" ], @@ -1015,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", "cpu": [ "arm" ], @@ -1029,9 +1029,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", "cpu": [ "arm64" ], @@ -1043,9 +1043,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", "cpu": [ "arm64" ], @@ -1057,9 +1057,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", "cpu": [ "loong64" ], @@ -1071,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", "cpu": [ "ppc64" ], @@ -1085,9 +1085,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", "cpu": [ "riscv64" ], @@ -1099,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", "cpu": [ "riscv64" ], @@ -1113,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", "cpu": [ "s390x" ], @@ -1127,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "cpu": [ "x64" ], @@ -1141,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", "cpu": [ "x64" ], @@ -1155,9 +1155,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", "cpu": [ "arm64" ], @@ -1169,9 +1169,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", "cpu": [ "arm64" ], @@ -1183,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", "cpu": [ "ia32" ], @@ -1197,9 +1197,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", "cpu": [ "x64" ], @@ -1211,9 +1211,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", "cpu": [ "x64" ], @@ -1232,9 +1232,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", - "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1252,9 +1252,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.48.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.4.tgz", - "integrity": "sha512-TGFX1pZUt9qqY20Cv5NyYvy0iLWHf2jXi8s+eCGsig7jQMdwZWKUFMR6TbvFNhfDSUpc1sH/Y5EHv20g3HHA3g==", + "version": "2.49.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.1.tgz", + "integrity": "sha512-vByReCTTdlNM80vva8alAQC80HcOiHLkd8XAxIiKghKSHcqeNfyhp3VsYAV8VSiPKu4Jc8wWCfsZNAIvd1uCqA==", "dev": true, "license": "MIT", "dependencies": { @@ -2765,9 +2765,9 @@ } }, "node_modules/devalue": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.2.tgz", - "integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz", + "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==", "dev": true, "license": "MIT" }, @@ -3128,9 +3128,9 @@ } }, "node_modules/esrap": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz", - "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3653,9 +3653,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3862,9 +3862,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.1.tgz", - "integrity": "sha512-arzsi9IZjjByiEw21gSUP82qHM8zkV69nNpWV6W4z72KiLvsDWoOp678ORV6cNfU/JGhlX0SsnD4oXo9gI6I2A==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.3.tgz", + "integrity": "sha512-/5rpGC0eK8LlFqsHaBmL19/PVKxu/CCt8pO1vzp9X6SDLsRDh/Ccudkf3Ur5lyaKxJz9ndAx+LaThdv0ySqB6A==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3872,9 +3872,9 @@ "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", - "@types/statuses": "^2.0.4", + "@types/statuses": "^2.0.6", "cookie": "^1.0.2", - "graphql": "^16.8.1", + "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", @@ -3884,7 +3884,7 @@ "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", - "type-fest": "^4.26.1", + "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, @@ -3907,36 +3907,43 @@ } }, "node_modules/msw/node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/msw/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.0.tgz", + "integrity": "sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g==", "dev": true, "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nanoid": { @@ -4130,13 +4137,13 @@ } }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -4149,9 +4156,9 @@ } }, "node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4299,9 +4306,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "peer": true, @@ -4464,9 +4471,9 @@ } }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4480,28 +4487,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" } }, @@ -4726,9 +4733,9 @@ } }, "node_modules/svelte": { - "version": "5.43.6", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.6.tgz", - "integrity": "sha512-RnyO9VXI85Bmsf4b8AuQFBKFYL3LKUl+ZrifOjvlrQoboAROj5IITVLK1yOXBjwUWUn2BI5cfmurktgCzuZ5QA==", + "version": "5.45.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.4.tgz", + "integrity": "sha512-1/Y6ZfAQ30GjebMrDFM8ktL1WZ0ylljLabotDAFN41MTrDOY4gGzDDYnIKV+p8YXcnxEEDpVfVWE+I6u69jJ7A==", "dev": true, "license": "MIT", "dependencies": { @@ -4740,8 +4747,9 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.5.0", "esm-env": "^1.2.1", - "esrap": "^2.1.0", + "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -4863,6 +4871,19 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4963,22 +4984,22 @@ } }, "node_modules/tldts": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", - "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.17" + "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", - "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", "dev": true, "license": "MIT" }, @@ -5327,9 +5348,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6691,9 +6712,9 @@ } }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "optional": true, @@ -6703,6 +6724,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/svelte-test/package.json b/svelte-test/package.json index 9c4184b..6b2b6cc 100644 --- a/svelte-test/package.json +++ b/svelte-test/package.json @@ -41,6 +41,6 @@ "type": "module", "dependencies": { "@rainlanguage/float": "^0.0.0-alpha.22", - "@rainlanguage/sqlite-web": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.7.tgz" + "@rainlanguage/sqlite-web": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.8.tgz" } } diff --git a/svelte-test/src/routes/sql/+page.svelte b/svelte-test/src/routes/sql/+page.svelte index 44aaa39..792a5dc 100644 --- a/svelte-test/src/routes/sql/+page.svelte +++ b/svelte-test/src/routes/sql/+page.svelte @@ -26,16 +26,6 @@ } db = res.value; - status = 'Setting up database schema...'; - await db.query(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - status = 'Ready βœ…'; } catch (error) { status = `Failed: ${error instanceof Error ? error.message : 'Unknown error'}`; @@ -83,10 +73,10 @@

Status: {status}

{#if status.includes('Ready')} -
-
-

SQL Query

-
+
+
+

SQL Query

+
From 8a15774432861c2cd1e5fc58b71f56b73f471f20 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 10:44:58 +0300 Subject: [PATCH 03/10] add tests --- packages/sqlite-web-core/src/coordination.rs | 120 +++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/packages/sqlite-web-core/src/coordination.rs b/packages/sqlite-web-core/src/coordination.rs index 45134b0..983ee21 100644 --- a/packages/sqlite-web-core/src/coordination.rs +++ b/packages/sqlite-web-core/src/coordination.rs @@ -6,6 +6,8 @@ use uuid::Uuid; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::{spawn_local, JsFuture}; +#[cfg(all(test, target_family = "wasm"))] +use wasm_bindgen_test::*; use web_sys::{ Blob, BlobPropertyBag, BroadcastChannel, DedicatedWorkerGlobalScope, MessageEvent, Url, Worker, }; @@ -828,3 +830,121 @@ pub async fn sleep_ms(ms: i32) { }); let _ = JsFuture::from(promise).await; } + +#[cfg(all(test, target_family = "wasm"))] +mod tests { + use super::*; + use crate::messages::ChannelMessage; + use crate::util::sanitize_identifier; + use std::cell::RefCell; + + wasm_bindgen_test_configure!(run_in_browser); + + fn set_global_str(key: &str, value: &str) { + let _ = Reflect::set( + &js_sys::global(), + &JsValue::from_str(key), + &JsValue::from_str(value), + ); + } + + fn set_global_num(key: &str, value: f64) { + let _ = Reflect::set( + &js_sys::global(), + &JsValue::from_str(key), + &JsValue::from_f64(value), + ); + } + + #[wasm_bindgen_test(async)] + async fn coordinator_broadcasts_leader_and_ready() { + set_global_str("__SQLITE_DB_NAME", "testdb-coordinator"); + set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 100.0); + set_global_str( + "__SQLITE_EMBEDDED_WORKER", + "self.postMessage({type:'worker-ready'}); self.onmessage = ev => { const d = ev.data || {}; if (d.type === 'execute-query') { self.postMessage({type:'query-result', requestId:d.requestId, result:'{\"ok\":true}', error:null}); } };", + ); + + let cfg = worker_config_from_global().expect("config"); + let state = CoordinatorState::new(cfg).expect("state"); + + let channel_name = format!("sqlite-queries-{}", sanitize_identifier(&state.db_name)); + let observer = BroadcastChannel::new(&channel_name).expect("observer channel"); + + let received: Rc>> = Rc::new(RefCell::new(Vec::new())); + let recv_clone = Rc::clone(&received); + let listener = Closure::wrap(Box::new(move |event: MessageEvent| { + if let Ok(msg) = serde_wasm_bindgen::from_value::(event.data()) { + recv_clone.borrow_mut().push(msg); + } + }) as Box); + observer.set_onmessage(Some(listener.as_ref().unchecked_ref())); + listener.forget(); + + state.on_lock_granted(); + sleep_ms(50).await; + + let msgs = received.borrow(); + assert!( + msgs.iter() + .any(|m| matches!(m, ChannelMessage::NewLeader { .. })), + "should announce new-leader" + ); + assert!( + msgs.iter() + .any(|m| matches!(m, ChannelMessage::LeaderReady { .. })), + "should announce leader-ready" + ); + } + + #[wasm_bindgen_test(async)] + async fn leader_ping_responds_based_on_db_readiness() { + set_global_str("__SQLITE_DB_NAME", "testdb-ping"); + set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 50.0); + set_global_str("__SQLITE_EMBEDDED_WORKER", ""); + + let cfg = worker_config_from_global().expect("config"); + let state = CoordinatorState::new(cfg).expect("state"); + *state.role.borrow_mut() = LeadershipRole::Leader; + + let channel_name = format!("sqlite-queries-{}", sanitize_identifier(&state.db_name)); + let observer = BroadcastChannel::new(&channel_name).expect("observer channel"); + + let received: Rc>> = Rc::new(RefCell::new(Vec::new())); + let recv_clone = Rc::clone(&received); + let listener = Closure::wrap(Box::new(move |event: MessageEvent| { + if let Ok(msg) = serde_wasm_bindgen::from_value::(event.data()) { + recv_clone.borrow_mut().push(msg); + } + }) as Box); + observer.set_onmessage(Some(listener.as_ref().unchecked_ref())); + listener.forget(); + + *state.db_worker_ready.borrow_mut() = false; + state.handle_channel_message(ChannelMessage::LeaderPing { + requester_id: "follower".to_string(), + }); + sleep_ms(10).await; + assert!( + received + .borrow() + .iter() + .any(|m| matches!(m, ChannelMessage::NewLeader { .. })), + "should answer with new-leader when DB not ready" + ); + + received.borrow_mut().clear(); + *state.db_worker_ready.borrow_mut() = true; + state.handle_channel_message(ChannelMessage::LeaderPing { + requester_id: "follower".to_string(), + }); + sleep_ms(10).await; + assert!( + received + .borrow() + .iter() + .any(|m| matches!(m, ChannelMessage::LeaderReady { .. })), + "should answer with leader-ready once DB is ready" + ); + } +} From e40f1df1e71709f5d2f8fab163b1ec374968b13d Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 10:55:21 +0300 Subject: [PATCH 04/10] update --- packages/sqlite-web-core/src/coordination.rs | 81 ++++++++++++++++---- 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/packages/sqlite-web-core/src/coordination.rs b/packages/sqlite-web-core/src/coordination.rs index 983ee21..0cb1fc5 100644 --- a/packages/sqlite-web-core/src/coordination.rs +++ b/packages/sqlite-web-core/src/coordination.rs @@ -1,6 +1,6 @@ use js_sys::{Function, Object, Promise, Reflect}; -use std::cell::RefCell; -use std::collections::HashMap; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; use std::rc::Rc; use uuid::Uuid; use wasm_bindgen::prelude::*; @@ -74,6 +74,12 @@ enum DbRequestOrigin { Forwarded { query_id: String }, } +struct DbJob { + request_id: u32, + sql: String, + params: Option>, +} + pub struct CoordinatorState { pub worker_id: String, pub role: Rc>, @@ -93,6 +99,8 @@ pub struct CoordinatorState { pub struct DbWorkerState { pub db: Rc>>, pub db_name: String, + db_queue: Rc>>, + db_processing: Rc>, } pub fn create_broadcast_channel(db_name: &str) -> Result { @@ -197,7 +205,6 @@ impl CoordinatorState { spawn_local(async move { if let Err(err) = state.acquire_lock_and_promote().await { let _ = send_worker_error_message(&js_value_to_string(&err)); - state.promote_without_lock(); } }); } @@ -323,14 +330,21 @@ impl CoordinatorState { } } - fn handle_db_worker_failure(&self, error: String) { + fn handle_db_worker_failure(self: &Rc, error: String) { *self.db_worker_ready.borrow_mut() = false; *self.leader_ready.borrow_mut() = false; + *self.ready_signaled.borrow_mut() = false; + if let Some(worker) = self.db_worker.borrow_mut().take() { + worker.terminate(); + } let _ = send_worker_error_message(&error); let pending = self.db_pending.borrow_mut().drain().collect::>(); for (_, origin) in pending { self.fail_origin(origin, error.clone()); } + if let Err(err) = self.spawn_db_worker() { + let _ = send_worker_error_message(&js_value_to_string(&err)); + } } pub fn handle_main_message(self: &Rc, msg: WorkerMessage) { @@ -605,6 +619,8 @@ impl DbWorkerState { Rc::new(DbWorkerState { db: Rc::new(RefCell::new(None)), db_name: config.db_name, + db_queue: Rc::new(RefCell::new(VecDeque::new())), + db_processing: Rc::new(Cell::new(false)), }) } @@ -630,21 +646,54 @@ impl DbWorkerState { sql, params, } => { - let db = Rc::clone(&self.db); - spawn_local(async move { - let result = exec_on_db(db, sql, params).await; - match make_query_result_message(request_id, result) { - Ok(resp) => { - let _ = post_worker_message(&resp); - } - Err(err) => { - let _ = send_worker_error(err); - } - } - }); + self.enqueue_query(request_id, sql, params); } } } + + fn enqueue_query( + self: &Rc, + request_id: u32, + sql: String, + params: Option>, + ) { + self.db_queue.borrow_mut().push_back(DbJob { + request_id, + sql, + params, + }); + self.start_queue_processor(); + } + + fn start_queue_processor(self: &Rc) { + if self.db_processing.replace(true) { + return; + } + let state = Rc::clone(self); + spawn_local(async move { + loop { + let job = { + let mut queue = state.db_queue.borrow_mut(); + queue.pop_front() + }; + let Some(job) = job else { break }; + let db = Rc::clone(&state.db); + let result = exec_on_db(db, job.sql, job.params).await; + match make_query_result_message(job.request_id, result) { + Ok(resp) => { + let _ = post_worker_message(&resp); + } + Err(err) => { + let _ = send_worker_error(err); + } + } + } + state.db_processing.set(false); + if !state.db_queue.borrow().is_empty() { + state.start_queue_processor(); + } + }); + } } fn send_channel_message( From 20bf205ee4629786ca13d79a8ad658a73d5925e1 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 11:15:04 +0300 Subject: [PATCH 05/10] update tests --- packages/sqlite-web-core/src/coordination.rs | 225 ++++++++++++++++++- 1 file changed, 216 insertions(+), 9 deletions(-) diff --git a/packages/sqlite-web-core/src/coordination.rs b/packages/sqlite-web-core/src/coordination.rs index 0cb1fc5..f6c0120 100644 --- a/packages/sqlite-web-core/src/coordination.rs +++ b/packages/sqlite-web-core/src/coordination.rs @@ -215,8 +215,7 @@ impl CoordinatorState { let locks = Reflect::get(&navigator, &JsValue::from_str("locks"))?; let request_value = Reflect::get(&locks, &JsValue::from_str("request"))?; let Some(request_fn) = request_value.dyn_ref::() else { - self.promote_without_lock(); - return Ok(()); + return Err(JsValue::from_str("navigator.locks.request unavailable")); }; let options = Object::new(); @@ -238,10 +237,6 @@ impl CoordinatorState { Ok(()) } - fn promote_without_lock(self: &Rc) { - self.on_lock_granted(); - } - fn on_lock_granted(self: &Rc) { *self.role.borrow_mut() = LeadershipRole::Leader; self.mark_leader_known(self.worker_id.clone()); @@ -303,6 +298,10 @@ impl CoordinatorState { pub fn handle_db_worker_event(self: &Rc, event: MessageEvent) { let data = event.data(); + self.handle_db_worker_value(data); + } + + pub fn handle_db_worker_value(self: &Rc, data: JsValue) { match serde_wasm_bindgen::from_value::(data.clone()) { Ok(MainThreadMessage::WorkerReady) => { *self.db_worker_ready.borrow_mut() = true; @@ -680,9 +679,7 @@ impl DbWorkerState { let db = Rc::clone(&state.db); let result = exec_on_db(db, job.sql, job.params).await; match make_query_result_message(job.request_id, result) { - Ok(resp) => { - let _ = post_worker_message(&resp); - } + Ok(resp) => deliver_db_result(&resp), Err(err) => { let _ = send_worker_error(err); } @@ -821,6 +818,29 @@ pub fn send_query_result_to_main( post_worker_message(&message).map_err(|err| JsValue::from_str(&err)) } +#[cfg(not(all(test, target_family = "wasm")))] +fn deliver_db_result(obj: &js_sys::Object) { + if let Err(err) = post_worker_message(obj) { + let _ = send_worker_error(JsValue::from_str(&err)); + } +} + +#[cfg(all(test, target_family = "wasm"))] +fn deliver_db_result(obj: &js_sys::Object) { + let global = js_sys::global(); + let key = JsValue::from_str("__SQLITE_TEST_DB_RESULTS"); + let current = Reflect::get(&global, &key).unwrap_or(JsValue::UNDEFINED); + let array = if current.is_object() && js_sys::Array::is_array(¤t) { + current.unchecked_into::() + } else { + let a = js_sys::Array::new(); + let _ = Reflect::set(&global, &key, a.as_ref()); + a + }; + array.push(obj); +} + +#[cfg(not(all(test, target_family = "wasm")))] async fn exec_on_db( db: Rc>>, sql: String, @@ -841,6 +861,28 @@ async fn exec_on_db( result } +#[cfg(all(test, target_family = "wasm"))] +async fn exec_on_db( + _db: Rc>>, + _sql: String, + _params: Option>, +) -> Result { + // Test double: mark busy to detect concurrent access + let global = js_sys::global(); + let busy_key = JsValue::from_str("__SQLITE_TEST_FAKE_DB_BUSY"); + let busy = Reflect::get(&global, &busy_key) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if busy { + return Err("concurrent".to_string()); + } + let _ = Reflect::set(&global, &busy_key, &JsValue::TRUE); + sleep_ms(5).await; + let _ = Reflect::set(&global, &busy_key, &JsValue::FALSE); + Ok("fake-db-ok".to_string()) +} + pub async fn sleep_ms(ms: i32) { let promise = js_sys::Promise::new(&mut |resolve, _| { let resolve_for_timeout = resolve.clone(); @@ -885,6 +927,7 @@ mod tests { use super::*; use crate::messages::ChannelMessage; use crate::util::sanitize_identifier; + use js_sys::Array; use std::cell::RefCell; wasm_bindgen_test_configure!(run_in_browser); @@ -905,6 +948,11 @@ mod tests { ); } + fn set_global_bool(key: &str, value: bool) { + let val = if value { JsValue::TRUE } else { JsValue::FALSE }; + let _ = Reflect::set(&js_sys::global(), &JsValue::from_str(key), &val); + } + #[wasm_bindgen_test(async)] async fn coordinator_broadcasts_leader_and_ready() { set_global_str("__SQLITE_DB_NAME", "testdb-coordinator"); @@ -996,4 +1044,163 @@ mod tests { "should answer with leader-ready once DB is ready" ); } + + #[wasm_bindgen_test(async)] + async fn lock_request_failure_keeps_follower_role() { + set_global_str("__SQLITE_DB_NAME", "testdb-lock-failure"); + set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 50.0); + set_global_str("__SQLITE_EMBEDDED_WORKER", ""); + + // Stub navigator.locks.request to throw so acquisition fails. + let global = js_sys::global(); + let navigator = Reflect::get(&global, &JsValue::from_str("navigator")) + .unwrap_or_else(|_| JsValue::UNDEFINED); + let navigator_obj = if navigator.is_object() { + navigator.unchecked_into::() + } else { + let obj = js_sys::Object::new(); + let _ = Reflect::set(&global, &JsValue::from_str("navigator"), obj.as_ref()); + obj + }; + + let locks = Reflect::get(&navigator_obj, &JsValue::from_str("locks")) + .unwrap_or_else(|_| JsValue::UNDEFINED); + let locks_obj = if locks.is_object() { + locks.unchecked_into::() + } else { + let obj = js_sys::Object::new(); + let _ = Reflect::set(&navigator_obj, &JsValue::from_str("locks"), obj.as_ref()); + obj + }; + + let request = js_sys::Function::new_no_args("throw new Error('no-lock');"); + let _ = Reflect::set(&locks_obj, &JsValue::from_str("request"), request.as_ref()); + + let cfg = worker_config_from_global().expect("config"); + let state = CoordinatorState::new(cfg).expect("state"); + let result = state.acquire_lock_and_promote().await; + + assert!(result.is_err(), "lock acquisition should fail"); + assert_eq!(*state.role.borrow(), LeadershipRole::Follower); + assert!(state.leader_id.borrow().is_none()); + assert!(!*state.leader_ready.borrow()); + assert!(!*state.ready_signaled.borrow()); + } + + #[wasm_bindgen_test(async)] + async fn db_worker_failure_resets_and_reports() { + set_global_str("__SQLITE_DB_NAME", "testdb-db-failure"); + set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 50.0); + set_global_str("__SQLITE_EMBEDDED_WORKER", ""); + + let cfg = worker_config_from_global().expect("config"); + let state = CoordinatorState::new(cfg).expect("state"); + *state.role.borrow_mut() = LeadershipRole::Leader; + *state.db_worker_ready.borrow_mut() = true; + *state.leader_ready.borrow_mut() = true; + *state.ready_signaled.borrow_mut() = true; + + // Track pending forwarded response + let query_id = "q1".to_string(); + state.db_pending.borrow_mut().insert( + 7, + DbRequestOrigin::Forwarded { + query_id: query_id.clone(), + }, + ); + + let channel_name = format!("sqlite-queries-{}", sanitize_identifier(&state.db_name)); + let observer = BroadcastChannel::new(&channel_name).expect("observer channel"); + let received: Rc>> = Rc::new(RefCell::new(Vec::new())); + let recv_clone = Rc::clone(&received); + let listener = Closure::wrap(Box::new(move |event: MessageEvent| { + if let Ok(msg) = serde_wasm_bindgen::from_value::(event.data()) { + recv_clone.borrow_mut().push(msg); + } + }) as Box); + observer.set_onmessage(Some(listener.as_ref().unchecked_ref())); + listener.forget(); + + let data = js_sys::Object::new(); + let _ = Reflect::set( + &data, + &JsValue::from_str("type"), + &JsValue::from_str("worker-error"), + ); + let _ = Reflect::set( + &data, + &JsValue::from_str("error"), + &JsValue::from_str("boom"), + ); + state.handle_db_worker_value(data.into()); + sleep_ms(20).await; + + assert!(!*state.db_worker_ready.borrow()); + assert!(!*state.leader_ready.borrow()); + assert!(!*state.ready_signaled.borrow()); + + let has_error_response = received.borrow().iter().any(|msg| { + matches!( + msg, + ChannelMessage::QueryResponse { + query_id: qid, + result: _, + error: Some(err), + } if qid == &query_id && err == "boom" + ) + }); + assert!( + has_error_response, + "forwarded pending query should be errored" + ); + } + + #[wasm_bindgen_test(async)] + async fn db_worker_queue_serializes_requests() { + set_global_bool("__SQLITE_TEST_FAKE_DB", true); + set_global_bool("__SQLITE_TEST_FAKE_DB_BUSY", false); + let _ = Reflect::set( + &js_sys::global(), + &JsValue::from_str("__SQLITE_TEST_DB_RESULTS"), + Array::new().as_ref(), + ); + + let state = DbWorkerState::new(WorkerConfig { + db_name: "testdb-fake".to_string(), + follower_timeout_ms: 10.0, + }); + + state.handle_message(WorkerMessage::ExecuteQuery { + request_id: 1, + sql: "SELECT 1".to_string(), + params: None, + }); + state.handle_message(WorkerMessage::ExecuteQuery { + request_id: 2, + sql: "SELECT 2".to_string(), + params: None, + }); + + sleep_ms(30).await; + + let results_val = Reflect::get( + &js_sys::global(), + &JsValue::from_str("__SQLITE_TEST_DB_RESULTS"), + ) + .unwrap_or(JsValue::UNDEFINED); + let results = results_val.unchecked_into::(); + assert_eq!(results.length(), 2, "both queued queries should complete"); + for entry in results.iter() { + let result = Reflect::get(&entry, &JsValue::from_str("result")) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(); + assert_eq!(result, "fake-db-ok"); + let error = Reflect::get(&entry, &JsValue::from_str("error")) + .ok() + .filter(|v| !v.is_null() && !v.is_undefined()) + .map(|v| js_value_to_string(&v)); + assert!(error.is_none(), "no error expected"); + } + } } From f14bd70e9eb683b9ebdf8012573fb0662fb43709 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 11:25:19 +0300 Subject: [PATCH 06/10] update --- svelte-test/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/svelte-test/package-lock.json b/svelte-test/package-lock.json index 95a5a67..af97b34 100644 --- a/svelte-test/package-lock.json +++ b/svelte-test/package-lock.json @@ -914,7 +914,7 @@ "node_modules/@rainlanguage/sqlite-web": { "version": "0.0.1-alpha.8", "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.8.tgz", - "integrity": "sha512-n3l2AMSlxyeWle1+S2bl4OqPc206mW1o/TstFEfJex2F4D2uqOSBKzb7s2DbNtVF134hARp8VpJi+GxQN5t7LQ==" + "integrity": "sha512-ziuTPLPnOylCNFclfkrzpHonS0TYud5tEiEbzglH4+BuN03Pcm7Ac3XWbw4L5DZSN5L8tTi8Ezfy1pUI940U/Q==" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.3", From 412d7be4498117a4c22c56da28e6e655b909eff4 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 12:20:44 +0300 Subject: [PATCH 07/10] update --- packages/sqlite-web-core/src/coordination.rs | 5 ----- svelte-test/package-lock.json | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/sqlite-web-core/src/coordination.rs b/packages/sqlite-web-core/src/coordination.rs index f6c0120..acc83b8 100644 --- a/packages/sqlite-web-core/src/coordination.rs +++ b/packages/sqlite-web-core/src/coordination.rs @@ -42,11 +42,6 @@ pub fn worker_config_from_global() -> Result { } Ok(trimmed) } else { - #[cfg(test)] - { - return Ok("testdb".to_string()); - } - #[allow(unreachable_code)] Err(JsValue::from_str("Database name is required")) } } diff --git a/svelte-test/package-lock.json b/svelte-test/package-lock.json index af97b34..201a599 100644 --- a/svelte-test/package-lock.json +++ b/svelte-test/package-lock.json @@ -914,7 +914,7 @@ "node_modules/@rainlanguage/sqlite-web": { "version": "0.0.1-alpha.8", "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.8.tgz", - "integrity": "sha512-ziuTPLPnOylCNFclfkrzpHonS0TYud5tEiEbzglH4+BuN03Pcm7Ac3XWbw4L5DZSN5L8tTi8Ezfy1pUI940U/Q==" + "integrity": "sha512-dHCG1bFJJtDni+nWmwboTaHWwXqpBLBodGTi79MO/9uHSEoGjEDVyzSw2HW47ad8oK58xXAyyChWPbWNmgOxww==" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.3", From 5b7866725f077e2af7bbcf39ba402f98ae52429c Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 12:30:13 +0300 Subject: [PATCH 08/10] update --- packages/sqlite-web-core/src/coordination.rs | 137 ++++++++++--------- svelte-test/package-lock.json | 2 +- 2 files changed, 74 insertions(+), 65 deletions(-) diff --git a/packages/sqlite-web-core/src/coordination.rs b/packages/sqlite-web-core/src/coordination.rs index acc83b8..5fd84eb 100644 --- a/packages/sqlite-web-core/src/coordination.rs +++ b/packages/sqlite-web-core/src/coordination.rs @@ -1,6 +1,8 @@ use js_sys::{Function, Object, Promise, Reflect}; use std::cell::{Cell, RefCell}; use std::collections::{HashMap, VecDeque}; +use std::future::Future; +use std::pin::Pin; use std::rc::Rc; use uuid::Uuid; use wasm_bindgen::prelude::*; @@ -75,6 +77,35 @@ struct DbJob { params: Option>, } +type DbExecFuture = Pin> + 'static>>; +type DbExecFn = dyn Fn( + Rc>>, + String, + Option>, +) -> DbExecFuture; +type DbDeliverFn = dyn Fn(&js_sys::Object); + +#[derive(Clone)] +pub struct DbWorkerHooks { + exec: Rc, + deliver: Rc, +} + +impl DbWorkerHooks { + pub fn new(exec: Rc, deliver: Rc) -> Self { + Self { exec, deliver } + } +} + +impl Default for DbWorkerHooks { + fn default() -> Self { + Self { + exec: Rc::new(|db, sql, params| Box::pin(exec_on_db(db, sql, params))), + deliver: Rc::new(deliver_db_result), + } + } +} + pub struct CoordinatorState { pub worker_id: String, pub role: Rc>, @@ -96,6 +127,7 @@ pub struct DbWorkerState { pub db_name: String, db_queue: Rc>>, db_processing: Rc>, + hooks: DbWorkerHooks, } pub fn create_broadcast_channel(db_name: &str) -> Result { @@ -610,11 +642,16 @@ impl CoordinatorState { impl DbWorkerState { pub fn new(config: WorkerConfig) -> Rc { + Self::new_with_hooks(config, DbWorkerHooks::default()) + } + + pub fn new_with_hooks(config: WorkerConfig, hooks: DbWorkerHooks) -> Rc { Rc::new(DbWorkerState { db: Rc::new(RefCell::new(None)), db_name: config.db_name, db_queue: Rc::new(RefCell::new(VecDeque::new())), db_processing: Rc::new(Cell::new(false)), + hooks, }) } @@ -664,6 +701,7 @@ impl DbWorkerState { return; } let state = Rc::clone(self); + let hooks = state.hooks.clone(); spawn_local(async move { loop { let job = { @@ -672,9 +710,11 @@ impl DbWorkerState { }; let Some(job) = job else { break }; let db = Rc::clone(&state.db); - let result = exec_on_db(db, job.sql, job.params).await; + let exec = Rc::clone(&hooks.exec); + let deliver = Rc::clone(&hooks.deliver); + let result = exec.as_ref()(db, job.sql, job.params).await; match make_query_result_message(job.request_id, result) { - Ok(resp) => deliver_db_result(&resp), + Ok(resp) => deliver.as_ref()(&resp), Err(err) => { let _ = send_worker_error(err); } @@ -813,29 +853,11 @@ pub fn send_query_result_to_main( post_worker_message(&message).map_err(|err| JsValue::from_str(&err)) } -#[cfg(not(all(test, target_family = "wasm")))] fn deliver_db_result(obj: &js_sys::Object) { if let Err(err) = post_worker_message(obj) { let _ = send_worker_error(JsValue::from_str(&err)); } } - -#[cfg(all(test, target_family = "wasm"))] -fn deliver_db_result(obj: &js_sys::Object) { - let global = js_sys::global(); - let key = JsValue::from_str("__SQLITE_TEST_DB_RESULTS"); - let current = Reflect::get(&global, &key).unwrap_or(JsValue::UNDEFINED); - let array = if current.is_object() && js_sys::Array::is_array(¤t) { - current.unchecked_into::() - } else { - let a = js_sys::Array::new(); - let _ = Reflect::set(&global, &key, a.as_ref()); - a - }; - array.push(obj); -} - -#[cfg(not(all(test, target_family = "wasm")))] async fn exec_on_db( db: Rc>>, sql: String, @@ -856,28 +878,6 @@ async fn exec_on_db( result } -#[cfg(all(test, target_family = "wasm"))] -async fn exec_on_db( - _db: Rc>>, - _sql: String, - _params: Option>, -) -> Result { - // Test double: mark busy to detect concurrent access - let global = js_sys::global(); - let busy_key = JsValue::from_str("__SQLITE_TEST_FAKE_DB_BUSY"); - let busy = Reflect::get(&global, &busy_key) - .ok() - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if busy { - return Err("concurrent".to_string()); - } - let _ = Reflect::set(&global, &busy_key, &JsValue::TRUE); - sleep_ms(5).await; - let _ = Reflect::set(&global, &busy_key, &JsValue::FALSE); - Ok("fake-db-ok".to_string()) -} - pub async fn sleep_ms(ms: i32) { let promise = js_sys::Promise::new(&mut |resolve, _| { let resolve_for_timeout = resolve.clone(); @@ -923,7 +923,7 @@ mod tests { use crate::messages::ChannelMessage; use crate::util::sanitize_identifier; use js_sys::Array; - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; wasm_bindgen_test_configure!(run_in_browser); @@ -943,11 +943,6 @@ mod tests { ); } - fn set_global_bool(key: &str, value: bool) { - let val = if value { JsValue::TRUE } else { JsValue::FALSE }; - let _ = Reflect::set(&js_sys::global(), &JsValue::from_str(key), &val); - } - #[wasm_bindgen_test(async)] async fn coordinator_broadcasts_leader_and_ready() { set_global_str("__SQLITE_DB_NAME", "testdb-coordinator"); @@ -1152,18 +1147,38 @@ mod tests { #[wasm_bindgen_test(async)] async fn db_worker_queue_serializes_requests() { - set_global_bool("__SQLITE_TEST_FAKE_DB", true); - set_global_bool("__SQLITE_TEST_FAKE_DB_BUSY", false); - let _ = Reflect::set( - &js_sys::global(), - &JsValue::from_str("__SQLITE_TEST_DB_RESULTS"), - Array::new().as_ref(), + let results = Rc::new(Array::new()); + let busy_flag = Rc::new(Cell::new(false)); + let hooks = DbWorkerHooks::new( + { + let busy_flag = Rc::clone(&busy_flag); + Rc::new(move |_db, _sql, _params| { + let busy_flag = Rc::clone(&busy_flag); + Box::pin(async move { + if busy_flag.replace(true) { + return Err("concurrent".to_string()); + } + sleep_ms(5).await; + busy_flag.set(false); + Ok("fake-db-ok".to_string()) + }) + }) + }, + { + let results = Rc::clone(&results); + Rc::new(move |obj: &js_sys::Object| { + results.push(obj.as_ref()); + }) + }, ); - let state = DbWorkerState::new(WorkerConfig { - db_name: "testdb-fake".to_string(), - follower_timeout_ms: 10.0, - }); + let state = DbWorkerState::new_with_hooks( + WorkerConfig { + db_name: "testdb-fake".to_string(), + follower_timeout_ms: 10.0, + }, + hooks, + ); state.handle_message(WorkerMessage::ExecuteQuery { request_id: 1, @@ -1178,12 +1193,6 @@ mod tests { sleep_ms(30).await; - let results_val = Reflect::get( - &js_sys::global(), - &JsValue::from_str("__SQLITE_TEST_DB_RESULTS"), - ) - .unwrap_or(JsValue::UNDEFINED); - let results = results_val.unchecked_into::(); assert_eq!(results.length(), 2, "both queued queries should complete"); for entry in results.iter() { let result = Reflect::get(&entry, &JsValue::from_str("result")) diff --git a/svelte-test/package-lock.json b/svelte-test/package-lock.json index 201a599..3d3e57f 100644 --- a/svelte-test/package-lock.json +++ b/svelte-test/package-lock.json @@ -914,7 +914,7 @@ "node_modules/@rainlanguage/sqlite-web": { "version": "0.0.1-alpha.8", "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.8.tgz", - "integrity": "sha512-dHCG1bFJJtDni+nWmwboTaHWwXqpBLBodGTi79MO/9uHSEoGjEDVyzSw2HW47ad8oK58xXAyyChWPbWNmgOxww==" + "integrity": "sha512-soCLdu4NdzPeSiTubZnFC103z0kgDsVxbB0miaPGDZTmaQzaYJ+t5u4L9hVMHtvnv8HBf4YdJVk6qDMTTRAO9g==" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.3", From 69d9b9bda3949c5f4622f23abd1421df45c19b73 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 12:43:15 +0300 Subject: [PATCH 09/10] add query timeout as separate variable --- packages/sqlite-web-core/src/coordination.rs | 49 +++++++++++++++++++- packages/sqlite-web/src/tests.rs | 4 ++ packages/sqlite-web/src/worker_template.rs | 6 ++- svelte-test/package-lock.json | 2 +- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/sqlite-web-core/src/coordination.rs b/packages/sqlite-web-core/src/coordination.rs index 5fd84eb..3810534 100644 --- a/packages/sqlite-web-core/src/coordination.rs +++ b/packages/sqlite-web-core/src/coordination.rs @@ -30,6 +30,7 @@ pub enum LeadershipRole { pub struct WorkerConfig { pub db_name: String, pub follower_timeout_ms: f64, + pub query_timeout_ms: f64, } pub fn worker_config_from_global() -> Result { @@ -60,9 +61,22 @@ pub fn worker_config_from_global() -> Result { 5000.0 } + fn get_query_timeout_from_global() -> f64 { + let global = js_sys::global(); + let val = Reflect::get(&global, &JsValue::from_str("__SQLITE_QUERY_TIMEOUT_MS")) + .unwrap_or(JsValue::UNDEFINED); + if let Some(n) = val.as_f64() { + if n.is_finite() && n >= 0.0 { + return n; + } + } + 30000.0 + } + Ok(WorkerConfig { db_name: get_db_name_from_global()?, follower_timeout_ms: get_follower_timeout_from_global(), + query_timeout_ms: get_query_timeout_from_global(), }) } @@ -113,6 +127,7 @@ pub struct CoordinatorState { pub leader_ready: Rc>, pub ready_signaled: Rc>, pub follower_timeout_ms: f64, + pub query_timeout_ms: f64, pub channel: BroadcastChannel, pub db_worker_ready: Rc>, pub db_worker: Rc>>, @@ -144,6 +159,7 @@ impl CoordinatorState { leader_ready: Rc::new(RefCell::new(false)), ready_signaled: Rc::new(RefCell::new(false)), follower_timeout_ms: config.follower_timeout_ms, + query_timeout_ms: config.query_timeout_ms, channel: create_broadcast_channel(&config.db_name)?, db_worker_ready: Rc::new(RefCell::new(false)), db_worker: Rc::new(RefCell::new(None)), @@ -296,9 +312,10 @@ impl CoordinatorState { .as_string() .ok_or_else(|| JsValue::from_str("Embedded worker source is missing"))?; let preamble = format!( - "self.__SQLITE_DB_ONLY = true;\nself.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = {};\n", + "self.__SQLITE_DB_ONLY = true;\nself.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = {};\nself.__SQLITE_QUERY_TIMEOUT_MS = {};\n", db_name_encoded, self.follower_timeout_ms, + self.query_timeout_ms, ); let parts = js_sys::Array::new(); @@ -403,7 +420,7 @@ impl CoordinatorState { .borrow_mut() .insert(query_id.clone(), request_id); let pending = Rc::clone(&self.follower_pending); - let timeout = self.follower_timeout_ms; + let timeout = self.query_timeout_ms; let timeout_query_id = query_id.clone(); spawn_local(async move { sleep_ms(timeout.ceil() as i32).await; @@ -943,10 +960,34 @@ mod tests { ); } + #[wasm_bindgen_test] + fn worker_config_reads_custom_timeouts() { + set_global_str("__SQLITE_DB_NAME", "testdb-timeouts"); + set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 1234.0); + set_global_num("__SQLITE_QUERY_TIMEOUT_MS", 4321.0); + + let cfg = worker_config_from_global().expect("config"); + assert_eq!(cfg.follower_timeout_ms, 1234.0); + assert_eq!(cfg.query_timeout_ms, 4321.0); + } + + #[wasm_bindgen_test] + fn worker_config_defaults_query_timeout() { + set_global_str("__SQLITE_DB_NAME", "testdb-timeouts-default"); + let _ = Reflect::delete_property( + &js_sys::global(), + &JsValue::from_str("__SQLITE_QUERY_TIMEOUT_MS"), + ); + + let cfg = worker_config_from_global().expect("config"); + assert_eq!(cfg.query_timeout_ms, 30000.0); + } + #[wasm_bindgen_test(async)] async fn coordinator_broadcasts_leader_and_ready() { set_global_str("__SQLITE_DB_NAME", "testdb-coordinator"); set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 100.0); + set_global_num("__SQLITE_QUERY_TIMEOUT_MS", 100.0); set_global_str( "__SQLITE_EMBEDDED_WORKER", "self.postMessage({type:'worker-ready'}); self.onmessage = ev => { const d = ev.data || {}; if (d.type === 'execute-query') { self.postMessage({type:'query-result', requestId:d.requestId, result:'{\"ok\":true}', error:null}); } };", @@ -988,6 +1029,7 @@ mod tests { async fn leader_ping_responds_based_on_db_readiness() { set_global_str("__SQLITE_DB_NAME", "testdb-ping"); set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 50.0); + set_global_num("__SQLITE_QUERY_TIMEOUT_MS", 50.0); set_global_str("__SQLITE_EMBEDDED_WORKER", ""); let cfg = worker_config_from_global().expect("config"); @@ -1039,6 +1081,7 @@ mod tests { async fn lock_request_failure_keeps_follower_role() { set_global_str("__SQLITE_DB_NAME", "testdb-lock-failure"); set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 50.0); + set_global_num("__SQLITE_QUERY_TIMEOUT_MS", 50.0); set_global_str("__SQLITE_EMBEDDED_WORKER", ""); // Stub navigator.locks.request to throw so acquisition fails. @@ -1081,6 +1124,7 @@ mod tests { async fn db_worker_failure_resets_and_reports() { set_global_str("__SQLITE_DB_NAME", "testdb-db-failure"); set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 50.0); + set_global_num("__SQLITE_QUERY_TIMEOUT_MS", 50.0); set_global_str("__SQLITE_EMBEDDED_WORKER", ""); let cfg = worker_config_from_global().expect("config"); @@ -1176,6 +1220,7 @@ mod tests { WorkerConfig { db_name: "testdb-fake".to_string(), follower_timeout_ms: 10.0, + query_timeout_ms: 10.0, }, hooks, ); diff --git a/packages/sqlite-web/src/tests.rs b/packages/sqlite-web/src/tests.rs index 2365b28..05edaa8 100644 --- a/packages/sqlite-web/src/tests.rs +++ b/packages/sqlite-web/src/tests.rs @@ -133,6 +133,10 @@ fn test_worker_template_generation() { worker_code.contains("__SQLITE_FOLLOWER_TIMEOUT_MS"), "Worker template should embed follower timeout configuration" ); + assert!( + worker_code.contains("__SQLITE_QUERY_TIMEOUT_MS"), + "Worker template should embed query timeout configuration" + ); } #[wasm_bindgen_test(async)] diff --git a/packages/sqlite-web/src/worker_template.rs b/packages/sqlite-web/src/worker_template.rs index f41a07e..67f6e44 100644 --- a/packages/sqlite-web/src/worker_template.rs +++ b/packages/sqlite-web/src/worker_template.rs @@ -7,7 +7,7 @@ pub fn generate_self_contained_worker(db_name: &str) -> String { let embedded_body = serde_json::to_string(include_str!("embedded_worker.js")) .unwrap_or_else(|_| "\"\"".to_string()); let prefix = format!( - "self.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = 5000.0;\nself.__SQLITE_EMBEDDED_WORKER = {};\n", + "self.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = 5000.0;\nself.__SQLITE_QUERY_TIMEOUT_MS = 30000.0;\nself.__SQLITE_EMBEDDED_WORKER = {};\n", encoded, embedded_body ); // Use the bundled worker template with embedded WASM @@ -33,6 +33,10 @@ mod tests { output.contains("self.__SQLITE_FOLLOWER_TIMEOUT_MS = 5000.0;"), "timeout constant should be injected" ); + assert!( + output.contains("self.__SQLITE_QUERY_TIMEOUT_MS = 30000.0;"), + "query timeout constant should be injected" + ); assert!( output.contains("self.__SQLITE_EMBEDDED_WORKER = "), "embedded worker body should be stored on the global" diff --git a/svelte-test/package-lock.json b/svelte-test/package-lock.json index 3d3e57f..0faf21d 100644 --- a/svelte-test/package-lock.json +++ b/svelte-test/package-lock.json @@ -914,7 +914,7 @@ "node_modules/@rainlanguage/sqlite-web": { "version": "0.0.1-alpha.8", "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.8.tgz", - "integrity": "sha512-soCLdu4NdzPeSiTubZnFC103z0kgDsVxbB0miaPGDZTmaQzaYJ+t5u4L9hVMHtvnv8HBf4YdJVk6qDMTTRAO9g==" + "integrity": "sha512-1vYpXVkzxwZrhVkGHmPy49NT6iqBDPF6EfELz5wnDM3e4fiqRpClsBCIMrdAZxr86JUUx5COpeONMm/q/AUYwg==" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.3", From fd8c2f73fe69fa7170603627faa3f8a8da0efd8f Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Wed, 3 Dec 2025 12:59:07 +0300 Subject: [PATCH 10/10] ai comment updates --- packages/sqlite-web-core/src/coordination.rs | 80 ++++++++++++++++---- packages/sqlite-web/src/worker_template.rs | 1 + svelte-test/package-lock.json | 2 +- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/packages/sqlite-web-core/src/coordination.rs b/packages/sqlite-web-core/src/coordination.rs index 3810534..31376df 100644 --- a/packages/sqlite-web-core/src/coordination.rs +++ b/packages/sqlite-web-core/src/coordination.rs @@ -27,6 +27,8 @@ pub enum LeadershipRole { Follower, } +const MAX_DB_WORKER_RESPAWNS: u32 = 3; + pub struct WorkerConfig { pub db_name: String, pub follower_timeout_ms: f64, @@ -135,6 +137,7 @@ pub struct CoordinatorState { db_pending: Rc>>, pub follower_pending: Rc>>, pub next_db_request_id: Rc>, + db_worker_restart_attempts: Rc>, } pub struct DbWorkerState { @@ -167,6 +170,7 @@ impl CoordinatorState { db_pending: Rc::new(RefCell::new(HashMap::new())), follower_pending: Rc::new(RefCell::new(HashMap::new())), next_db_request_id: Rc::new(RefCell::new(1)), + db_worker_restart_attempts: Rc::new(Cell::new(0)), })) } @@ -296,8 +300,6 @@ impl CoordinatorState { } fn spawn_db_worker(self: &Rc) -> Result<(), JsValue> { - let db_name_encoded = - serde_json::to_string(&self.db_name).unwrap_or_else(|_| "\"unknown\"".to_string()); let body_val = Reflect::get( &js_sys::global(), &JsValue::from_str("__SQLITE_EMBEDDED_WORKER"), @@ -311,16 +313,36 @@ impl CoordinatorState { let body = body_val .as_string() .ok_or_else(|| JsValue::from_str("Embedded worker source is missing"))?; - let preamble = format!( + let preamble = self.build_worker_preamble(); + let worker = Self::create_worker_from_script(&preamble, &body)?; + + let state = Rc::clone(self); + let handler = Closure::wrap(Box::new(move |event: MessageEvent| { + state.handle_db_worker_event(event); + }) as Box); + worker.set_onmessage(Some(handler.as_ref().unchecked_ref())); + handler.forget(); + + self.db_worker.borrow_mut().replace(worker); + Ok(()) + } + + fn build_worker_preamble(&self) -> String { + let db_name_encoded = + serde_json::to_string(&self.db_name).unwrap_or_else(|_| "\"unknown\"".to_string()); + // __SQLITE_DB_ONLY=true runs the embedded worker in DB-only mode, separating coordinator work from DB tasks. + format!( "self.__SQLITE_DB_ONLY = true;\nself.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = {};\nself.__SQLITE_QUERY_TIMEOUT_MS = {};\n", db_name_encoded, self.follower_timeout_ms, self.query_timeout_ms, - ); + ) + } + fn create_worker_from_script(preamble: &str, body: &str) -> Result { let parts = js_sys::Array::new(); - parts.push(&JsValue::from_str(&preamble)); - parts.push(&JsValue::from_str(&body)); + parts.push(&JsValue::from_str(preamble)); + parts.push(&JsValue::from_str(body)); let options = BlobPropertyBag::new(); options.set_type("application/javascript"); let blob = Blob::new_with_str_sequence_and_options(&parts, &options)?; @@ -328,16 +350,7 @@ impl CoordinatorState { let worker = Worker::new(&url)?; Url::revoke_object_url(&url)?; - - let state = Rc::clone(self); - let handler = Closure::wrap(Box::new(move |event: MessageEvent| { - state.handle_db_worker_event(event); - }) as Box); - worker.set_onmessage(Some(handler.as_ref().unchecked_ref())); - handler.forget(); - - self.db_worker.borrow_mut().replace(worker); - Ok(()) + Ok(worker) } pub fn handle_db_worker_event(self: &Rc, event: MessageEvent) { @@ -350,6 +363,7 @@ impl CoordinatorState { Ok(MainThreadMessage::WorkerReady) => { *self.db_worker_ready.borrow_mut() = true; *self.leader_ready.borrow_mut() = true; + self.db_worker_restart_attempts.set(0); let ready = ChannelMessage::LeaderReady { leader_id: self.worker_id.clone(), }; @@ -377,6 +391,8 @@ impl CoordinatorState { *self.db_worker_ready.borrow_mut() = false; *self.leader_ready.borrow_mut() = false; *self.ready_signaled.borrow_mut() = false; + let attempts = self.db_worker_restart_attempts.get().saturating_add(1); + self.db_worker_restart_attempts.set(attempts); if let Some(worker) = self.db_worker.borrow_mut().take() { worker.terminate(); } @@ -385,6 +401,13 @@ impl CoordinatorState { for (_, origin) in pending { self.fail_origin(origin, error.clone()); } + if attempts > MAX_DB_WORKER_RESPAWNS { + let message = format!( + "DB worker restart limit reached (max {MAX_DB_WORKER_RESPAWNS}); leaving worker failed" + ); + let _ = send_worker_error_message(&message); + return; + } if let Err(err) = self.spawn_db_worker() { let _ = send_worker_error_message(&js_value_to_string(&err)); } @@ -1189,6 +1212,31 @@ mod tests { ); } + #[wasm_bindgen_test] + fn db_worker_failure_stops_after_max_retries() { + set_global_str("__SQLITE_DB_NAME", "testdb-db-failure-limit"); + set_global_num("__SQLITE_FOLLOWER_TIMEOUT_MS", 50.0); + set_global_num("__SQLITE_QUERY_TIMEOUT_MS", 50.0); + set_global_str("__SQLITE_EMBEDDED_WORKER", ""); + + let cfg = worker_config_from_global().expect("config"); + let state = CoordinatorState::new(cfg).expect("state"); + *state.db_worker_ready.borrow_mut() = true; + *state.leader_ready.borrow_mut() = true; + *state.ready_signaled.borrow_mut() = true; + + state.db_worker_restart_attempts.set(MAX_DB_WORKER_RESPAWNS); + state.handle_db_worker_failure("still broken".to_string()); + + assert!(!*state.db_worker_ready.borrow()); + assert!(!*state.ready_signaled.borrow()); + assert_eq!( + state.db_worker_restart_attempts.get(), + MAX_DB_WORKER_RESPAWNS + 1 + ); + assert!(state.db_worker.borrow().is_none()); + } + #[wasm_bindgen_test(async)] async fn db_worker_queue_serializes_requests() { let results = Rc::new(Array::new()); diff --git a/packages/sqlite-web/src/worker_template.rs b/packages/sqlite-web/src/worker_template.rs index 67f6e44..dc04b4a 100644 --- a/packages/sqlite-web/src/worker_template.rs +++ b/packages/sqlite-web/src/worker_template.rs @@ -6,6 +6,7 @@ pub fn generate_self_contained_worker(db_name: &str) -> String { let encoded = serde_json::to_string(db_name).unwrap_or_else(|_| "\"unknown\"".to_string()); let embedded_body = serde_json::to_string(include_str!("embedded_worker.js")) .unwrap_or_else(|_| "\"\"".to_string()); + // __SQLITE_EMBEDDED_WORKER stores the JSON-encoded embedded worker body (embedded_body) so the coordinator can spawn a separate DB worker (see coordination.rs:301-313); set when embedded-worker mode is used and consumers must JSON-decode before instantiating the worker. let prefix = format!( "self.__SQLITE_DB_NAME = {};\nself.__SQLITE_FOLLOWER_TIMEOUT_MS = 5000.0;\nself.__SQLITE_QUERY_TIMEOUT_MS = 30000.0;\nself.__SQLITE_EMBEDDED_WORKER = {};\n", encoded, embedded_body diff --git a/svelte-test/package-lock.json b/svelte-test/package-lock.json index 0faf21d..275a966 100644 --- a/svelte-test/package-lock.json +++ b/svelte-test/package-lock.json @@ -914,7 +914,7 @@ "node_modules/@rainlanguage/sqlite-web": { "version": "0.0.1-alpha.8", "resolved": "file:../pkg/rainlanguage-sqlite-web-0.0.1-alpha.8.tgz", - "integrity": "sha512-1vYpXVkzxwZrhVkGHmPy49NT6iqBDPF6EfELz5wnDM3e4fiqRpClsBCIMrdAZxr86JUUx5COpeONMm/q/AUYwg==" + "integrity": "sha512-twoXjmCwMHE+QX0fqRfbGguKzijOOOX52e6/+wWXV+xqpBt0xE25JATrHEqYA7dZWEWk7miZbh0/9atsgEbJXw==" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.3",