From 7a0efcfa3d7c74c4f8d640b03a86bee95f16c1d0 Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Sat, 2 Apr 2022 13:33:12 +0100 Subject: [PATCH 1/8] Start work on indexeddb wrapper (using futures) --- Cargo.toml | 11 +++++------ crates/storage/Cargo.toml | 14 +++++++++++++- crates/storage/src/indexeddb.rs | 11 +++++++++++ crates/storage/src/lib.rs | 19 +++++++++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 crates/storage/src/indexeddb.rs diff --git a/Cargo.toml b/Cargo.toml index 9bfea86f..2b1841e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,15 +33,14 @@ features = ["futures"] [workspace] members = [ - "crates/timers", + "crates/console", + "crates/dialogs", "crates/events", - "crates/net", "crates/file", - "crates/dialogs", + "crates/history", + "crates/net", "crates/storage", - "crates/console", + "crates/timers", "crates/utils", - "crates/history", "crates/worker", - "crates/net", ] diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 838f23f2..957b518c 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -12,16 +12,28 @@ categories = ["api-bindings", "storage", "wasm"] [dependencies] wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } +wasm-bindgen-futures = "0.4" serde = "1.0" serde_json = "1.0" thiserror = "1.0" js-sys = "0.3" gloo-utils = { version = "0.1", path = "../utils" } + [dependencies.web-sys] version = "0.3" features = [ - "Storage", "Window", + "Navigator", + "StorageManager", + "Storage", + # indexeddb + "IdbFactory", + "IdbOpenDbRequest", + "IdbVersionChangeEvent", + "IdbVersionChangeEventInit", + "IdbIndex", + "IdbCursor", + "IdbObjectStore", ] [dev-dependencies] diff --git a/crates/storage/src/indexeddb.rs b/crates/storage/src/indexeddb.rs new file mode 100644 index 00000000..64184043 --- /dev/null +++ b/crates/storage/src/indexeddb.rs @@ -0,0 +1,11 @@ +use wasm_bindgen::prelude::*; + +pub async fn open( + name: &str, + version: u32, + upgrade_fn: impl FnOnce() + 'static, +) -> Result { + Ok(Db) +} + +pub struct Db; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index f5713f67..5ac2c9b0 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -8,12 +8,15 @@ use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; use crate::errors::js_to_error; use errors::StorageError; +use gloo_utils::window; use serde_json::{Map, Value}; pub mod errors; +mod indexeddb; mod local_storage; mod session_storage; pub use local_storage::LocalStorage; @@ -95,3 +98,19 @@ pub trait Storage { .expect_throw("unreachable: length does not throw an exception") } } + +/// Request that stored data be persisted and not reclaimed unless the user specifically clears +/// their storage. +/// +/// Returns `true` if the request was granted, or `false` if not. +pub async fn persist() -> bool { + JsFuture::from(storage_manager().persist().unwrap_throw()) + .await + .unwrap_throw() + .is_truthy() +} + +/// Get the user agent's storage manager instance +fn storage_manager() -> web_sys::StorageManager { + window().navigator().storage() +} From 89fe0a457ffa46bf8d75a98d943d1cf77a9c3046 Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Sun, 3 Apr 2022 22:28:19 +0100 Subject: [PATCH 2/8] idb progress --- crates/storage/Cargo.toml | 8 +- crates/storage/src/indexed_db.rs | 311 +++++++++++++++++++++++++++++ crates/storage/src/indexeddb.rs | 11 - crates/storage/src/lib.rs | 2 +- crates/storage/tests/indexed_db.rs | 35 ++++ 5 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 crates/storage/src/indexed_db.rs delete mode 100644 crates/storage/src/indexeddb.rs create mode 100644 crates/storage/tests/indexed_db.rs diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 957b518c..6744b617 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -18,6 +18,9 @@ serde_json = "1.0" thiserror = "1.0" js-sys = "0.3" gloo-utils = { version = "0.1", path = "../utils" } +gloo-events = { version = "0.1", path = "../events" } +futures-channel = "0.3" +futures = "0.3" [dependencies.web-sys] version = "0.3" @@ -27,13 +30,16 @@ features = [ "StorageManager", "Storage", # indexeddb + "DomException", + "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbVersionChangeEvent", - "IdbVersionChangeEventInit", + "IdbDatabase", "IdbIndex", "IdbCursor", "IdbObjectStore", + "IdbObjectStoreParameters", ] [dev-dependencies] diff --git a/crates/storage/src/indexed_db.rs b/crates/storage/src/indexed_db.rs new file mode 100644 index 00000000..9e47c01a --- /dev/null +++ b/crates/storage/src/indexed_db.rs @@ -0,0 +1,311 @@ +//! A futures-based wrapper around indexed DB. +use std::ops::Index; + +use futures::StreamExt; +use gloo_events::EventListener; +use gloo_utils::window; +use wasm_bindgen::{prelude::*, throw_str, JsCast}; +use web_sys::{ + DomException, DomStringList, IdbDatabase, IdbFactory, IdbObjectStore, IdbObjectStoreParameters, + IdbOpenDbRequest, IdbRequest, IdbVersionChangeEvent, +}; + +/// This crate's result type. +pub type Result = std::result::Result; + +fn indexed_db() -> Option { + window().indexed_db().ok().flatten() +} + +/// Checks if indexed db is supported in the current context. +pub fn indexed_db_supported() -> bool { + indexed_db().is_some() +} + +/// Delete an indexed db database. +pub async fn delete_db(name: &str) { + let db_request = indexed_db() + .unwrap_throw() + .delete_database(name) + .unwrap_throw(); + + let (mut send, mut recv) = futures_channel::mpsc::channel(1); + let _success_listener = EventListener::new(&db_request, "success", { + let mut send = send.clone(); + move |_| { + send.try_send(Ok(())).expect_throw("try_send"); + } + }); + + let _error_listener = EventListener::new(&db_request, "error", move |_| { + send.try_send(Err(())).expect_throw("try_send") + }); + + recv.next().await.unwrap_throw().expect_throw("delete_db") +} + +/// An indexeddb database +#[derive(Debug)] +pub struct Db { + inner: IdbDatabase, +} + +impl Db { + // TODO should we handle the 'block' event? It doesn't mean failure, just + // that the future will not complete until other db instances are closed. + // Maybe promote to an error? + /// Open a database + /// + /// # Panics + /// + /// Will panic if `version` is `0`. + pub async fn open( + name: &str, + version: u32, + mut upgrade_fn: impl FnMut(DbUpgrade) + 'static, + ) -> Result { + if version == 0 { + throw_str("version must be at least 1"); + } + let db_request = indexed_db() + .ok_or(Error::IndexedDbNotFound)? + .open_with_u32(name, version) + .expect_throw("Db::open"); + + // Listeners keep the closures alive unless dropped, in which case they are cleaned up. + // Using `let _ = ...` would immediately drop the closure meaning it is not run. + let _upgrade_listener = EventListener::new(&db_request, "upgradeneeded", move |event| { + let event = event + .dyn_ref::() + .expect_throw("IdbVersionChangeEvent dyn_into"); + let old_version = event.old_version() as u32; + // newVersion is not optional on MDN - I think this is an IDL inaccuracy. + let new_version = event.new_version().expect_throw("new_version") as u32; + let db = event + .target() + .expect_throw("Event::target") + .dyn_into::() + .expect_throw("IdbOpenDbRequest dyn_into") + .result() + .expect_throw("IdbOpenDbRequest::result") + .dyn_into::() + .expect_throw("IdbDatabase dyn_into"); + + upgrade_fn(DbUpgrade { + old_version, + new_version, + db: Db { inner: db }, + }) + }); + + // Exactly one message should be sent on the channel, meaning there should never be back-pressure. + // So errors should never happen when sending. + let (mut send, mut recv) = futures_channel::mpsc::channel(1); + let _success_listener = EventListener::new(&db_request, "success", { + let mut send = send.clone(); + move |event| { + let db = event + .target() + .expect_throw("Event::target") + .dyn_into::() + .expect_throw("IdbOpenDbRequest::dyn_into") + .result() + .expect_throw("IdbOpenDbRequest::result") + .dyn_into::() + .expect_throw("IdbDatabase::dyn_into"); + send.try_send(Some(db)).expect_throw("try_send"); + } + }); + + let _error_listener = EventListener::new(&db_request, "error", move |_| { + send.try_send(None).expect_throw("try_send") + }); + + // After this await, either error or success will have fired (this also means upgrading will have taken place) + match recv.next().await.flatten() { + Some(db) => Ok(Db { inner: db }), + None => Err(Error::OpeningDb), + } + } + + /// Get the name of the db + pub fn name(&self) -> String { + self.inner.name() + } + + /// Get a list of all the object store names for the database. + /// + /// # Examples + /// + /// Does the db contain a "test" object store? + /// ```no_run + /// let contains_test: bool = db.object_store_names().contains("test").unwrap_throw(); + /// ``` + pub fn object_store_names(&self) -> DomStringList { + self.inner.object_store_names() + } + + /// Copy the object store names for this database into a `Vec`. + pub fn object_store_names_vec(&self) -> Vec { + let raw = self.object_store_names(); + let mut names = vec![]; + for i in 0..raw.length() { + names.push(raw.get(i).unwrap_throw()); + } + names + } +} + +/// Provides access to the database during an update event. +/// +/// Use this object to create/delete object stores and indexes. +#[derive(Debug)] +pub struct DbUpgrade { + /// The version we are upgrading from + pub old_version: u32, + /// The version we are upgrading to + pub new_version: u32, + db: Db, +} + +impl DbUpgrade { + /// Create a new object store in the database + /// + /// # Example + /// + /// ```no_run + /// db.create_object_store("test") + /// .auto_increment(false) + /// .key_path("key.path") + /// .build() + /// ``` + pub fn create_object_store<'a>(&'a self, name: &'a str) -> CreateObjectStore<'a> { + CreateObjectStore { + name, + params: IdbObjectStoreParameters::new(), + db: &self.db.inner, + } + } + /// Delete an object store from the database + /// + /// Returns `true` if an object store was deleted or `false` if no object store with + /// that name existed. Errors if the database has been deleted since the update started. + pub fn delete_object_store(&self, name: &str) -> Result { + match self.db.inner.delete_object_store(name) { + Ok(()) => Ok(true), + Err(error) => { + let error = error.dyn_into::().unwrap_throw(); + match error.name().as_str() { + "TransactionInactiveError" => Err(Error::DbRemoved), + "NotFoundError" => Ok(false), + e => throw_str(&format!("unexpected error {}", e)), + } + } + } + } +} + +/// Builder struct to create object stores +#[derive(Debug)] +pub struct CreateObjectStore<'a> { + name: &'a str, + params: IdbObjectStoreParameters, + db: &'a IdbDatabase, +} + +impl<'a> CreateObjectStore<'a> { + /// If `true`, the object store has a + /// [key generator](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#key_generator). + /// Defaults to `false`. + pub fn auto_increment(mut self, auto_increment: bool) -> Self { + self.params.auto_increment(auto_increment); + self + } + + /// The [key path](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#key_path) + /// to be used by the new object store. If empty or not specified, the object store is created without a key path and uses + /// [out-of-line keys](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#out-of-line_key). + /// You can also pass in an array as a `key_path`. + pub fn key_path(mut self, key_path: impl KeyPath) -> Self { + self.params.key_path(Some(&key_path.into_jsvalue())); + self + } + + /// Actually create the object store using the configured builder. + /// + /// # Panics + /// + /// This function will panic if `auto_increment` is set to `false` (the default), and `key_path` is empty or not set. + pub fn build(self) -> Result { + self.db + .create_object_store_with_optional_parameters(self.name, &self.params) + .map_err(|error| { + let error = error.dyn_into::().unwrap_throw(); + // TODO MDN is a little vague here - so it would be worth revisiting and nailing down + // exact behavior + match error.name().as_str() { + "TransactionInactiveError" => Error::DbRemoved, + "ConstraintError" => Error::ObjectStoreAlreadyExists, + "InvalidAccessError" => { + throw_str("auto_increment is true and key_path is empty") + } + e => throw_str(&format!("unexpected error {}", e)), + } + }) + } +} + +mod sealed { + pub trait Sealed {} + + impl<'a> Sealed for &'a str {} + impl<'a, T> Sealed for &'a [T] where T: AsRef + 'a {} +} + +/// A trait for types that can be used as a key path when creating an object store. +/// +/// Types allowed are either a string or an array of strings. An empty string is +/// equivalent to not setting the key path. +pub trait KeyPath: sealed::Sealed { + /// Internal - please ignore + /// + /// Converts self into a value to use as the keyPath (must be a JsValue) + fn into_jsvalue(self) -> JsValue; +} + +impl<'a> KeyPath for &'a str { + fn into_jsvalue(self) -> JsValue { + JsValue::from(self) + } +} + +impl<'a, T> KeyPath for &'a [T] +where + T: AsRef + 'a, +{ + fn into_jsvalue(self) -> JsValue { + let arr = js_sys::Array::new(); + for i in 0..self.len() { + arr.push(&JsValue::from(self[i].as_ref())); + } + JsValue::from(arr) + } +} + +/// Represents an error using indexeddb. +#[derive(Debug)] +pub enum Error { + /// An error occurred while opening a database. + OpeningDb, + /// The current context does not support indexed db. + IndexedDbNotFound, + /// Attempted to create an object store on a database that had already + /// been deleted. + DbRemoved, + /// An object store already exists with the same name as one being + /// created (case sensitive). + ObjectStoreAlreadyExists, + /// A custom error - not used by the crate but allows users to pass their + /// own errors through in certain circumstances. + Custom(Box), +} diff --git a/crates/storage/src/indexeddb.rs b/crates/storage/src/indexeddb.rs deleted file mode 100644 index 64184043..00000000 --- a/crates/storage/src/indexeddb.rs +++ /dev/null @@ -1,11 +0,0 @@ -use wasm_bindgen::prelude::*; - -pub async fn open( - name: &str, - version: u32, - upgrade_fn: impl FnOnce() + 'static, -) -> Result { - Ok(Db) -} - -pub struct Db; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 5ac2c9b0..ddbbf412 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -16,7 +16,7 @@ use gloo_utils::window; use serde_json::{Map, Value}; pub mod errors; -mod indexeddb; +pub mod indexed_db; mod local_storage; mod session_storage; pub use local_storage::LocalStorage; diff --git a/crates/storage/tests/indexed_db.rs b/crates/storage/tests/indexed_db.rs new file mode 100644 index 00000000..8ef44fcc --- /dev/null +++ b/crates/storage/tests/indexed_db.rs @@ -0,0 +1,35 @@ +use gloo_storage::indexed_db::{delete_db, Db, DbUpgrade, Error}; +use serde::Deserialize; +use wasm_bindgen::UnwrapThrowExt; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn create_db() { + delete_db("dbname").await; + Db::open("dbname", 1, |_| ()).await.unwrap_throw(); +} + +fn db_upgrade(db: DbUpgrade) { + if db.old_version < 1 && db.new_version >= 1 { + db.create_object_store("name") + .auto_increment(true) + .key_path("id") + .build() + .unwrap_throw(); + } + if db.old_version < 2 && db.new_version >= 2 { + db.delete_object_store("name").unwrap_throw(); + } +} + +#[wasm_bindgen_test] +async fn create_delete_object_store() { + delete_db("dbname").await; + let db = Db::open("dbname", 1, db_upgrade).await.unwrap_throw(); + assert_eq!(db.object_store_names_vec(), vec!["name"]); + drop(db); + let db = Db::open("dbname", 2, db_upgrade).await.unwrap_throw(); + assert!(db.object_store_names().length() == 0); +} From 1a2d764d74027765688b1cf7fd5aff53e28083fa Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Sat, 9 Apr 2022 16:24:00 +0100 Subject: [PATCH 3/8] Save before changing to uninhabited enums --- crates/storage/Cargo.toml | 20 +- crates/storage/src/indexed_db.rs | 311 ---------- crates/storage/src/indexed_db/cursor.rs | 199 ++++++ crates/storage/src/indexed_db/errors.rs | 385 ++++++++++++ crates/storage/src/indexed_db/index.rs | 13 + crates/storage/src/indexed_db/key.rs | 257 ++++++++ crates/storage/src/indexed_db/mod.rs | 568 ++++++++++++++++++ crates/storage/src/indexed_db/object_store.rs | 421 +++++++++++++ crates/storage/src/indexed_db/transaction.rs | 161 +++++ crates/storage/src/indexed_db/util.rs | 78 +++ crates/storage/src/lib.rs | 2 + crates/storage/src/macros.rs | 25 + crates/storage/tests/indexed_db.rs | 79 ++- examples/storage/.gitignore | 1 + examples/storage/Cargo.toml | 31 + examples/storage/README.md | 7 + examples/storage/index.html | 15 + examples/storage/src/lib.rs | 175 ++++++ 18 files changed, 2411 insertions(+), 337 deletions(-) delete mode 100644 crates/storage/src/indexed_db.rs create mode 100644 crates/storage/src/indexed_db/cursor.rs create mode 100644 crates/storage/src/indexed_db/errors.rs create mode 100644 crates/storage/src/indexed_db/index.rs create mode 100644 crates/storage/src/indexed_db/key.rs create mode 100644 crates/storage/src/indexed_db/mod.rs create mode 100644 crates/storage/src/indexed_db/object_store.rs create mode 100644 crates/storage/src/indexed_db/transaction.rs create mode 100644 crates/storage/src/indexed_db/util.rs create mode 100644 crates/storage/src/macros.rs create mode 100644 examples/storage/.gitignore create mode 100644 examples/storage/Cargo.toml create mode 100644 examples/storage/README.md create mode 100644 examples/storage/index.html create mode 100644 examples/storage/src/lib.rs diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 6744b617..82b32ccb 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -15,12 +15,14 @@ wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } wasm-bindgen-futures = "0.4" serde = "1.0" serde_json = "1.0" -thiserror = "1.0" +thiserror = "1.0.30" js-sys = "0.3" gloo-utils = { version = "0.1", path = "../utils" } gloo-events = { version = "0.1", path = "../events" } futures-channel = "0.3" futures = "0.3" +once_cell = "1.10.0" +serde-wasm-bindgen = "0.4.2" [dependencies.web-sys] version = "0.3" @@ -32,14 +34,22 @@ features = [ # indexeddb "DomException", "DomStringList", - "IdbFactory", - "IdbOpenDbRequest", - "IdbVersionChangeEvent", + "IdbCursor", + "IdbCursorWithValue", + "IdbCursorDirection", "IdbDatabase", + "IdbFactory", "IdbIndex", - "IdbCursor", + "IdbIndexParameters", + "IdbKeyRange", "IdbObjectStore", "IdbObjectStoreParameters", + "IdbOpenDbRequest", + "IdbRequest", + "IdbRequestReadyState", + "IdbTransaction", + "IdbTransactionMode", + "IdbVersionChangeEvent", ] [dev-dependencies] diff --git a/crates/storage/src/indexed_db.rs b/crates/storage/src/indexed_db.rs deleted file mode 100644 index 9e47c01a..00000000 --- a/crates/storage/src/indexed_db.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! A futures-based wrapper around indexed DB. -use std::ops::Index; - -use futures::StreamExt; -use gloo_events::EventListener; -use gloo_utils::window; -use wasm_bindgen::{prelude::*, throw_str, JsCast}; -use web_sys::{ - DomException, DomStringList, IdbDatabase, IdbFactory, IdbObjectStore, IdbObjectStoreParameters, - IdbOpenDbRequest, IdbRequest, IdbVersionChangeEvent, -}; - -/// This crate's result type. -pub type Result = std::result::Result; - -fn indexed_db() -> Option { - window().indexed_db().ok().flatten() -} - -/// Checks if indexed db is supported in the current context. -pub fn indexed_db_supported() -> bool { - indexed_db().is_some() -} - -/// Delete an indexed db database. -pub async fn delete_db(name: &str) { - let db_request = indexed_db() - .unwrap_throw() - .delete_database(name) - .unwrap_throw(); - - let (mut send, mut recv) = futures_channel::mpsc::channel(1); - let _success_listener = EventListener::new(&db_request, "success", { - let mut send = send.clone(); - move |_| { - send.try_send(Ok(())).expect_throw("try_send"); - } - }); - - let _error_listener = EventListener::new(&db_request, "error", move |_| { - send.try_send(Err(())).expect_throw("try_send") - }); - - recv.next().await.unwrap_throw().expect_throw("delete_db") -} - -/// An indexeddb database -#[derive(Debug)] -pub struct Db { - inner: IdbDatabase, -} - -impl Db { - // TODO should we handle the 'block' event? It doesn't mean failure, just - // that the future will not complete until other db instances are closed. - // Maybe promote to an error? - /// Open a database - /// - /// # Panics - /// - /// Will panic if `version` is `0`. - pub async fn open( - name: &str, - version: u32, - mut upgrade_fn: impl FnMut(DbUpgrade) + 'static, - ) -> Result { - if version == 0 { - throw_str("version must be at least 1"); - } - let db_request = indexed_db() - .ok_or(Error::IndexedDbNotFound)? - .open_with_u32(name, version) - .expect_throw("Db::open"); - - // Listeners keep the closures alive unless dropped, in which case they are cleaned up. - // Using `let _ = ...` would immediately drop the closure meaning it is not run. - let _upgrade_listener = EventListener::new(&db_request, "upgradeneeded", move |event| { - let event = event - .dyn_ref::() - .expect_throw("IdbVersionChangeEvent dyn_into"); - let old_version = event.old_version() as u32; - // newVersion is not optional on MDN - I think this is an IDL inaccuracy. - let new_version = event.new_version().expect_throw("new_version") as u32; - let db = event - .target() - .expect_throw("Event::target") - .dyn_into::() - .expect_throw("IdbOpenDbRequest dyn_into") - .result() - .expect_throw("IdbOpenDbRequest::result") - .dyn_into::() - .expect_throw("IdbDatabase dyn_into"); - - upgrade_fn(DbUpgrade { - old_version, - new_version, - db: Db { inner: db }, - }) - }); - - // Exactly one message should be sent on the channel, meaning there should never be back-pressure. - // So errors should never happen when sending. - let (mut send, mut recv) = futures_channel::mpsc::channel(1); - let _success_listener = EventListener::new(&db_request, "success", { - let mut send = send.clone(); - move |event| { - let db = event - .target() - .expect_throw("Event::target") - .dyn_into::() - .expect_throw("IdbOpenDbRequest::dyn_into") - .result() - .expect_throw("IdbOpenDbRequest::result") - .dyn_into::() - .expect_throw("IdbDatabase::dyn_into"); - send.try_send(Some(db)).expect_throw("try_send"); - } - }); - - let _error_listener = EventListener::new(&db_request, "error", move |_| { - send.try_send(None).expect_throw("try_send") - }); - - // After this await, either error or success will have fired (this also means upgrading will have taken place) - match recv.next().await.flatten() { - Some(db) => Ok(Db { inner: db }), - None => Err(Error::OpeningDb), - } - } - - /// Get the name of the db - pub fn name(&self) -> String { - self.inner.name() - } - - /// Get a list of all the object store names for the database. - /// - /// # Examples - /// - /// Does the db contain a "test" object store? - /// ```no_run - /// let contains_test: bool = db.object_store_names().contains("test").unwrap_throw(); - /// ``` - pub fn object_store_names(&self) -> DomStringList { - self.inner.object_store_names() - } - - /// Copy the object store names for this database into a `Vec`. - pub fn object_store_names_vec(&self) -> Vec { - let raw = self.object_store_names(); - let mut names = vec![]; - for i in 0..raw.length() { - names.push(raw.get(i).unwrap_throw()); - } - names - } -} - -/// Provides access to the database during an update event. -/// -/// Use this object to create/delete object stores and indexes. -#[derive(Debug)] -pub struct DbUpgrade { - /// The version we are upgrading from - pub old_version: u32, - /// The version we are upgrading to - pub new_version: u32, - db: Db, -} - -impl DbUpgrade { - /// Create a new object store in the database - /// - /// # Example - /// - /// ```no_run - /// db.create_object_store("test") - /// .auto_increment(false) - /// .key_path("key.path") - /// .build() - /// ``` - pub fn create_object_store<'a>(&'a self, name: &'a str) -> CreateObjectStore<'a> { - CreateObjectStore { - name, - params: IdbObjectStoreParameters::new(), - db: &self.db.inner, - } - } - /// Delete an object store from the database - /// - /// Returns `true` if an object store was deleted or `false` if no object store with - /// that name existed. Errors if the database has been deleted since the update started. - pub fn delete_object_store(&self, name: &str) -> Result { - match self.db.inner.delete_object_store(name) { - Ok(()) => Ok(true), - Err(error) => { - let error = error.dyn_into::().unwrap_throw(); - match error.name().as_str() { - "TransactionInactiveError" => Err(Error::DbRemoved), - "NotFoundError" => Ok(false), - e => throw_str(&format!("unexpected error {}", e)), - } - } - } - } -} - -/// Builder struct to create object stores -#[derive(Debug)] -pub struct CreateObjectStore<'a> { - name: &'a str, - params: IdbObjectStoreParameters, - db: &'a IdbDatabase, -} - -impl<'a> CreateObjectStore<'a> { - /// If `true`, the object store has a - /// [key generator](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#key_generator). - /// Defaults to `false`. - pub fn auto_increment(mut self, auto_increment: bool) -> Self { - self.params.auto_increment(auto_increment); - self - } - - /// The [key path](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#key_path) - /// to be used by the new object store. If empty or not specified, the object store is created without a key path and uses - /// [out-of-line keys](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#out-of-line_key). - /// You can also pass in an array as a `key_path`. - pub fn key_path(mut self, key_path: impl KeyPath) -> Self { - self.params.key_path(Some(&key_path.into_jsvalue())); - self - } - - /// Actually create the object store using the configured builder. - /// - /// # Panics - /// - /// This function will panic if `auto_increment` is set to `false` (the default), and `key_path` is empty or not set. - pub fn build(self) -> Result { - self.db - .create_object_store_with_optional_parameters(self.name, &self.params) - .map_err(|error| { - let error = error.dyn_into::().unwrap_throw(); - // TODO MDN is a little vague here - so it would be worth revisiting and nailing down - // exact behavior - match error.name().as_str() { - "TransactionInactiveError" => Error::DbRemoved, - "ConstraintError" => Error::ObjectStoreAlreadyExists, - "InvalidAccessError" => { - throw_str("auto_increment is true and key_path is empty") - } - e => throw_str(&format!("unexpected error {}", e)), - } - }) - } -} - -mod sealed { - pub trait Sealed {} - - impl<'a> Sealed for &'a str {} - impl<'a, T> Sealed for &'a [T] where T: AsRef + 'a {} -} - -/// A trait for types that can be used as a key path when creating an object store. -/// -/// Types allowed are either a string or an array of strings. An empty string is -/// equivalent to not setting the key path. -pub trait KeyPath: sealed::Sealed { - /// Internal - please ignore - /// - /// Converts self into a value to use as the keyPath (must be a JsValue) - fn into_jsvalue(self) -> JsValue; -} - -impl<'a> KeyPath for &'a str { - fn into_jsvalue(self) -> JsValue { - JsValue::from(self) - } -} - -impl<'a, T> KeyPath for &'a [T] -where - T: AsRef + 'a, -{ - fn into_jsvalue(self) -> JsValue { - let arr = js_sys::Array::new(); - for i in 0..self.len() { - arr.push(&JsValue::from(self[i].as_ref())); - } - JsValue::from(arr) - } -} - -/// Represents an error using indexeddb. -#[derive(Debug)] -pub enum Error { - /// An error occurred while opening a database. - OpeningDb, - /// The current context does not support indexed db. - IndexedDbNotFound, - /// Attempted to create an object store on a database that had already - /// been deleted. - DbRemoved, - /// An object store already exists with the same name as one being - /// created (case sensitive). - ObjectStoreAlreadyExists, - /// A custom error - not used by the crate but allows users to pass their - /// own errors through in certain circumstances. - Custom(Box), -} diff --git a/crates/storage/src/indexed_db/cursor.rs b/crates/storage/src/indexed_db/cursor.rs new file mode 100644 index 00000000..5bb9acf4 --- /dev/null +++ b/crates/storage/src/indexed_db/cursor.rs @@ -0,0 +1,199 @@ +use super::{errors, StreamingRequest}; +use futures::stream::Stream; +use serde::Deserialize; +use std::{ + cell::Cell, + num::NonZeroU32, + ops::Deref, + pin::Pin, + task::{Context, Poll}, +}; +use wasm_bindgen::{prelude::*, throw_str, JsCast, UnwrapThrowExt}; +use web_sys::{IdbCursor, IdbCursorDirection, IdbCursorWithValue}; + +/// Represents an async stream of values from the DB. use the `Stream` impl to access the cursor +/// and its values. +#[derive(Debug)] +pub struct CursorStream { + /// Every time the request succeeds, its result is an instance of cursor. + request: StreamingRequest, +} + +impl CursorStream { + pub(crate) fn new(request: StreamingRequest) -> Self { + Self { request } + } +} + +impl Stream for CursorStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match Pin::new(&mut self.request).poll_next(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(None) => Poll::Ready(None), + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(errors::CursorError::from(e)))), + Poll::Ready(Some(Ok(next))) => { + let cursor = next + .dyn_into::() + .expect_throw("unreachable"); + Poll::Ready(Some(Ok(Cursor::new(cursor)))) + } + } + } +} + +/// A cursor for iterating through an object store (possibly filtered using a query). +/// +/// There are two types of cursors: those with values and those that only have the keys. This is +/// modelled by having `Cursor` (cursors with values) `Deref` to `KeyCursor` (cursors without +/// values). +#[derive(Debug)] +pub struct Cursor { + inner: KeyCursor, +} + +impl Cursor { + fn new(inner: IdbCursorWithValue) -> Self { + Self { + inner: KeyCursor::new(inner.into()), + } + } + + fn raw(&self) -> &IdbCursorWithValue { + self.inner.inner.unchecked_ref() + } + + /// Get the value at the current location of this cursor. + pub fn value_raw(&self) -> JsValue { + self.raw().value().expect_throw("unreachable") + } + + /// The value of the object the cursor is currently pointing to. + pub fn value(&self) -> Result + where + V: for<'de> Deserialize<'de>, + { + serde_wasm_bindgen::from_value(self.value_raw()) + } +} + +impl Deref for Cursor { + type Target = KeyCursor; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Wrapper round IDBCursor +#[derive(Debug)] +pub struct KeyCursor { + inner: IdbCursor, + /// Keep track of if the user has advanced the cursor somehow (if they don't we call `advance` + /// on drop) + advanced: Cell, +} + +impl KeyCursor { + fn new(inner: IdbCursor) -> Self { + Self { + inner, + advanced: Cell::new(false), + } + } + + /// The direction of the cursor. + pub fn direction(&self) -> CursorDirection { + self.inner.direction().into() + } + + /// Get the primary key for the current record. + pub fn primary_key_raw(&self) -> JsValue { + // Unwrap: the `Stream` implementation ensures that the cursor is valid and not moving + self.inner.primary_key().expect_throw("unreachable") + } + + /// Get the primary key for the current record. + pub fn primary_key(&self) -> Result + where + K: for<'de> Deserialize<'de>, + { + serde_wasm_bindgen::from_value(self.primary_key_raw()) + } + + /// Advance the cursor by the given value. + pub fn advance(self, amount: NonZeroU32) -> Result<(), errors::AdvanceError> { + self.inner + .advance(amount.get()) + .map_err(errors::AdvanceError::from)?; + self.advanced.set(true); + Ok(()) + } + + /// Move the cursor on to the next record. + /// + /// Equivalent to `cursor.advance(1)` + pub fn continue_(self) -> Result<(), errors::AdvanceError> { + self.inner.continue_().map_err(errors::AdvanceError::from)?; + self.advanced.set(true); + Ok(()) + } +} + +/// Possible modes a cursor can be in (fowards or backwards, and unique variants). +/// +/// Note that below the term `source` means 'the thing that this cursor points to', which could be +/// a whole object store, or some filtered and/or sorted part of it (e.g. using an index). +// Copy the defn to write our own docs, and panic on unknown constant. +#[derive(Debug)] +pub enum CursorDirection { + /// This direction causes the cursor to be opened at the start of the source of the cursor. + /// + /// When iterated, the cursor should yield all records, including duplicates, in monotonically + /// increasing order of keys. + Next, + /// This direction causes the cursor to be opened at the start of the source of the cursor. + /// + /// If multiple records have the same key, then only the first record is included. If + /// uniqueness of the key is enforced (using a `unique` index constraint) then all keys are + /// unique and this is the same as `Next`. + NextUnique, + /// This direction causes the cursor to be opened at the end of the source of the cursor. + /// + /// When iterated, the cursor should yield all records, including duplicates, in monotonically + /// decreasing order of keys. + Prev, + /// This direction causes the cursor to be opened at the end of the source of the cursor. + /// + /// If multiple records have the same key, then only the first record is included. If + /// uniqueness of the key is enforced (using a `unique` index constraint) then all keys are + /// unique and this is the same as `Next`. + /// + /// I'm not sure if 'first' here means the first going forward or going backward. The spec + /// seems to be ambiguous here. I would guess it means the same as for `NextUnique`. + PrevUnique, +} + +impl From for IdbCursorDirection { + fn from(input: CursorDirection) -> Self { + match input { + CursorDirection::Next => IdbCursorDirection::Next, + CursorDirection::NextUnique => IdbCursorDirection::Nextunique, + CursorDirection::Prev => IdbCursorDirection::Prev, + CursorDirection::PrevUnique => IdbCursorDirection::Prevunique, + } + } +} + +impl From for CursorDirection { + fn from(input: IdbCursorDirection) -> Self { + match input { + IdbCursorDirection::Next => CursorDirection::Next, + IdbCursorDirection::Nextunique => CursorDirection::NextUnique, + IdbCursorDirection::Prev => CursorDirection::Prev, + IdbCursorDirection::Prevunique => CursorDirection::PrevUnique, + _ => throw_str("unexpected indexeddb cursor direction"), + } + } +} diff --git a/crates/storage/src/indexed_db/errors.rs b/crates/storage/src/indexed_db/errors.rs new file mode 100644 index 00000000..7ccacc76 --- /dev/null +++ b/crates/storage/src/indexed_db/errors.rs @@ -0,0 +1,385 @@ +//! Various error types for operations in `indexed_db`. +//! +//! Some errors aren't handled explicitaly - these are the ones that we prevent using the Rust type +//! system. +use thiserror::Error; + +/// Errors that can occur when opening a database. +#[derive(Debug, Error)] +pub enum OpenDatabaseError { + /// Could not get the indexedDB singleton + #[error("indexeddb appears to be unsupported on this platform")] + IndexedDbUnsupported, + /// The database version was set to 0 + #[error("the database version was set to 0")] + InvalidVersion, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(OpenDatabaseError { + "TypeError" => InvalidVersion, +}); + +/// Errors that can occur when deleting a database. +#[derive(Debug, Error)] +pub enum DeleteDatabaseError { + /// Could not get the indexedDB singleton + #[error("indexeddb appears to be unsupported on this platform")] + IndexedDbUnsupported, + /// Another connection is blocking us + #[error("another connection is blocking us")] + WouldBlock, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(DeleteDatabaseError { + "TransactionWouldBlock" => WouldBlock, +}); + +/// Errors that can occur when creating an object store. +#[derive(Debug, Error)] +pub enum CreateObjectStoreError { + /// Could not get the indexedDB singleton + #[error("indexeddb appears to be unsupported on this platform")] + IndexedDbUnsupported, + /// The upgrade transaction has already finished + #[error("the upgrade transaction has already finished")] + TransactionInactive, + /// A store with the same name already exists + #[error("a store with the same name already exists")] + StoreAlreadyExists, + /// Trying to set `auto_increment = true` with an empty `key_path` + #[error("trying to set `auto_increment = true` with an empty `key_path`")] + InvalidConfig, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(CreateObjectStoreError { + "TransactionInactiveError" => TransactionInactive, + "ConstraintError" => StoreAlreadyExists, + "InvalidAccessError" => InvalidConfig, +}); + +/// Errors that can occur when deleting an object store +#[derive(Debug, Error)] +pub enum DeleteObjectStoreError { + /// The upgrade transaction has finished + #[error("the upgrade transaction has finished")] + TransactionInactive, + /// Tried to delete an object store that doesn't exist + #[error("tried to delete an object store that doesn't exist")] + ObjectStoreNotFound, + /// Unexpected error + #[error("unexepcted error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(DeleteObjectStoreError { + "TransactionInactiveError" => TransactionInactive, + "NotFoundError" => ObjectStoreNotFound +}); + +/// An error opening an object store from a transaction. +#[derive(Debug, Error)] +pub enum ObjectStoreError { + /// No object store with the given name was found (case sensitive) + #[error("no object store with the given name was found (case sensitive)")] + NotFound, + /// The object store was deleted or moved, or the transaction has finished + #[error("the object store was deleted or moved, or the transaction has finished")] + InvalidState, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(ObjectStoreError { + "NotFoundError" => NotFound, + "InvalidStateError" => InvalidState, +}); + +/// An error setting the name of an object store. +#[derive(Debug, Error)] +pub enum SetNameError { + /// an object store with the given name already exists. + #[error("an object store with the given name already exists")] + StoreWithNameExists, + /// Cannot change the name because the transaction has finished or been cancelled + #[error("cannot change the name because the transaction has finished or been cancelled")] + TransactionInactive, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +/// An error creating a new index on an object store +#[derive(Debug, Error)] +pub enum CreateIndexError { + /// An index with the given name already exists (index names are case sensitive) + #[error("an index with the given name already exists (case sensitive)")] + IndexWithNameExists, + /// Cannot set `multi_entry(true)` when `key_path` is a sequence + #[error("cannot set `multi_entry(true)` when `key_path` is a sequence")] + SequenceMultiEntry, + /// The object store we are trying to create an index for has been deleted + #[error("the object store we are trying to create an index for has been deleted")] + ObjectStoreDeleted, + /// The key path given to create_index isn't a valid key path + #[error("the key path given to create_index isn't a valid key path")] + InvalidKeyPath, + /// The upgrade transaction had finished before the index could be created + #[error("the upgrade transaction had finished before the index could be created")] + TransactionInactive, + /// Unexpected error creating index + #[error("unexpected error creating index: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(CreateIndexError { + "ConstraintError" => IndexWithNameExists, + "InvalidAccessError" => SequenceMultiEntry, + "InvalidStateError" => ObjectStoreDeleted, + "SyntaxError" => InvalidKeyPath, + "TransactionInactiveError" => TransactionInactive, +}); + +/// An error deleting an index +#[derive(Debug, Error)] +pub enum DeleteIndexError { + /// The upgrade transaction had finished before the index could be created + #[error("the upgrade transaction had finished before the index could be created")] + TransactionInactive, + /// No index was found with the given name + #[error("no index was found with the given name")] + NotFound, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(DeleteIndexError { + "TransactionInactiveError" => TransactionInactive, + "NotFoundError" => NotFound, +}); + +/// An error deleting an index +#[derive(Debug, Error)] +pub enum StartTransactionError { + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(StartTransactionError {}); + +/// An error adding an object to the store. +#[derive(Debug, Error)] +pub enum AddError { + /// Tried to add an object within a transaction that has finished + #[error("tried to add an object within a transaction that has finished")] + TransactionInactive, + /// Tried to add an invalid object - see [MDN](https://developer.mozilla.org/en-US/docs/Web/API/IDBObjectStore/add#dataerror) + #[error( + "tried to add an invalid object - see \ + [MDN](https://developer.mozilla.org/en-US/docs/Web/API/IDBObjectStore/add#dataerror)" + )] + InvalidData, + /// The object store was deleted or moved + #[error("the object store was deleted or moved")] + StoreNotFound, + /// The structural clone of the object to be added failed + #[error("the structural clone of the object to be added failed")] + CloneFailed, + /// Adding this object would violate a unique constraint + #[error("adding this object would violate a unique constraint")] + ConstraintViolated, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(AddError { + "TransactionInactiveError" => TransactionInactive, + "DataError" => InvalidData, + "InvalidStateError" => StoreNotFound, + "DataCloneError" => CloneFailed, + "ConstraintError" => ConstraintViolated, +}); + +/// An error when deleting all records from an object store +#[derive(Debug, Error)] +pub enum ClearError { + /// Tried to add an object within a transaction that has finished + #[error("tried to add an object within a transaction that has finished")] + TransactionInactive, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(ClearError { + "TransactionInactiveError" => TransactionInactive, +}); + +/// An error when deleting all records from an object store +#[derive(Debug, Error)] +pub enum CountError { + /// The object store was deleted or moved + #[error("the object store was deleted or moved")] + StoreNotFound, + /// Tried to count objects within a transaction that has finished + #[error("tried to count objects within a transaction that has finished")] + TransactionInactive, + /// The key or key range passed as a query was invalid + /// + /// The query option isn't implemented yet so this error will currently never occur. + #[error("the key or key range passed as a query was invalid")] + KeyRangeInvalid, + /// The number of records returned is greater than the maximum safe integer, meaning it may not + /// be accurate (not all numbers are representable as `f64`, JavaScript's number type). + #[error("returned count greater than the maximum safe integer (2^53-1)")] + CountTooBig, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(CountError { + "InvalidStateError" => StoreNotFound, + "TransactionInactiveError" => TransactionInactive, + "DataError" => KeyRangeInvalid, +}); + +/// An error when deleting objects from an objecct store +#[derive(Debug, Error)] +pub enum DeleteError { + /// Tried to delete objects within a transaction that has finished + #[error("tried to delete objects within a transaction that has finished")] + TransactionInactive, + /// The object store was deleted or moved + #[error("the object store was deleted or moved")] + StoreNotFound, + /// The given key was not a valid key + /// + /// This should only happen in edge cases. + #[error("the given key was not a valid key")] + InvalidKey, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(DeleteError { + "TransactionInactiveError" => TransactionInactive, + "InvalidStateError" => StoreNotFound, + "DataError" => InvalidKey, +}); + +/// An error when deleting objects from an objecct store +#[derive(Debug, Error)] +pub enum GetError { + /// Tried to get an object within a transaction that has finished + #[error("tried to get an object within a transaction that has finished")] + TransactionInactive, + /// The object store was deleted or moved + #[error("the object store was deleted or moved")] + StoreNotFound, + /// The given key was not a valid key + /// + /// This should only happen in edge cases. + #[error("the given key was not a valid key")] + InvalidKey, + /// Could not deserialize results + #[error("could not deserialize results")] + Deserialize(serde_wasm_bindgen::Error), + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(GetError { + "TransactionInactiveError" => TransactionInactive, + "InvalidStateError" => StoreNotFound, + "DataError" => InvalidKey, +}); + +/// An error when deleting objects from an objecct store +#[derive(Debug, Error)] +pub enum CursorError { + /// Tried to open an cursor within a transaction that has finished + #[error("tried to open a cursor within a transaction that has finished")] + TransactionInactive, + /// The object store was deleted or moved + #[error("the object store was deleted or moved")] + StoreNotFound, + /// The given key was not a valid key + /// + /// This should only happen in edge cases, and only when a query is used. + #[error("the given key was not a valid key")] + InvalidKey, + /// Could not deserialize results + #[error("could not deserialize results")] + Deserialize(serde_wasm_bindgen::Error), + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(CursorError { + "TransactionInactiveError" => TransactionInactive, + "InvalidStateError" => StoreNotFound, + "DataError" => InvalidKey, +}); + +/// An error when deleting objects from an objecct store +#[derive(Debug, Error)] +pub enum KeyRangeError { + /// Either upper < lower, upper = lower and at least one is closed, or upper or lower not a + /// valid key + // TODO options here are to panic on invalid ranges or check the conditions ourselves. + #[error("upper < lower, upper = lower and one is closed, or upper or lower not a valid key")] + InvalidParams, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(KeyRangeError { + "DataError" => InvalidParams, +}); + +/// An error when deleting objects from an objecct store +#[derive(Debug, Error)] +pub enum AdvanceError { + /// The amount was not a positive integer (should be unreachable) + #[error("The amount was not a positive integer (should be unreachable)")] + InvalidParams, + /// The transaction this cursor is attached to is no longer active + #[error("the transaction this cursor is attached to is no longer active")] + TransactionInactive, + /// The cursor is past the end of the object store (should be unreachable) + #[error("the cursor is past the end of the object store (should be unreachable)")] + PastEnd, + /// Unexpected error + #[error("unexpected error: {0}")] + Unexpected(String), +} + +error_from_jsvalue!(AdvanceError { + "TypeError" => InvalidParams, + "TransactionInactiveError" => TransactionInactive, + "InvalidStateError" => PastEnd, +}); + +// key conversions + +/// Tried to use a f64 NaN as a key +#[derive(Debug, Error)] +#[error("tried to use a f64 NaN as a key")] +pub struct NumberIsNan; diff --git a/crates/storage/src/indexed_db/index.rs b/crates/storage/src/indexed_db/index.rs new file mode 100644 index 00000000..d9996524 --- /dev/null +++ b/crates/storage/src/indexed_db/index.rs @@ -0,0 +1,13 @@ +use web_sys::IdbIndex; + +/// An object store index +#[derive(Debug)] +pub struct Index { + inner: IdbIndex, +} + +impl Index { + pub(crate) fn new(inner: IdbIndex) -> Self { + Self { inner } + } +} diff --git a/crates/storage/src/indexed_db/key.rs b/crates/storage/src/indexed_db/key.rs new file mode 100644 index 00000000..3c3abd55 --- /dev/null +++ b/crates/storage/src/indexed_db/key.rs @@ -0,0 +1,257 @@ +use super::{errors, indexed_db}; +use std::{ + cmp::Ordering, + convert::TryFrom, + ops::{Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive}, +}; +use wasm_bindgen::{prelude::*, throw_str}; +use web_sys::IdbKeyRange; + +mod sealed { + pub trait Sealed {} + + impl<'a> Sealed for &'a str {} + impl<'a, T> Sealed for &'a [T] where T: AsRef + 'a {} + impl Sealed for super::KeyPath {} +} + +// Key path +// -------- + +/// A trait for types that can be used as a key path when creating an object store. +/// +/// Types allowed are either a string or an array of strings. An empty string is +/// equivalent to not setting the key path. +pub trait IntoKeyPath: sealed::Sealed { + /// Internal - please ignore + /// + /// Converts self into a value to use as the keyPath (must be a JsValue) + fn into_jsvalue(self) -> JsValue; +} + +impl<'a> IntoKeyPath for &'a str { + fn into_jsvalue(self) -> JsValue { + JsValue::from(self) + } +} + +impl<'a, T> IntoKeyPath for &'a [T] +where + T: AsRef + 'a, +{ + fn into_jsvalue(self) -> JsValue { + let arr = js_sys::Array::new(); + for i in 0..self.len() { + arr.push(&JsValue::from(self[i].as_ref())); + } + JsValue::from(arr) + } +} + +impl IntoKeyPath for KeyPath { + fn into_jsvalue(self) -> JsValue { + match self { + KeyPath::None => JsValue::NULL, + KeyPath::String(s) => JsValue::from(s), + KeyPath::Sequence(multi) => multi + .iter() + .map(|s| JsValue::from(s)) + .collect::() + .into(), + } + } +} + +/// The different types that are allowed to be a key path. +#[derive(Debug)] +pub enum KeyPath { + /// No key path + None, + /// Single key path + String(String), + /// Multiple key paths + Sequence(Vec), +} + +// Key +// --- + +/// A valid indexedDB key +/// +/// # From [the spec] +/// +/// The following ECMAScript types are valid keys: +/// +/// - Number primitive values, except NaN. This includes Infinity and -Infinity. +/// - Date objects, except where the DateValue internal slot is NaN. +/// - String primitive values. +/// - ArrayBuffer objects (or views on buffers such as Uint8Array). +/// - Array objects, where every item is defined, is itself a valid key, and does not directly or +/// indirectly contain itself. This includes empty arrays. Arrays can contain other arrays. +/// +/// Attempting to convert other ECMAScript values to a key will fail. +/// +/// # Extra notes +/// +/// Keys are compared (for Eq, Ord, etc) using `window.indexedDB.cmp`. If indexeddb is not +/// supported, or the values are not valid keys, then the comparison functions will panic +/// +/// [the spec]: https://w3c.github.io/IndexedDB/#key-construct +#[derive(Debug)] +pub struct Key(pub(crate) JsValue); + +impl PartialEq for Key { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for Key {} + +impl PartialOrd for Key { + fn partial_cmp(&self, other: &Self) -> Option { + Some(Ord::cmp(self, other)) + } +} + +// My argument is that if these keys are used in indexes to a database, they have to obey the +// ordering rules for B-trees, therefore they almost certainly fulfil the contract for Eq/Ord. +impl Ord for Key { + fn cmp(&self, other: &Self) -> Ordering { + match indexed_db() + .expect_throw("indexeddb not supported") + .cmp(&self.0, &other.0) + .expect_throw("invalid key in indexedDB.cmp") + { + -1 => Ordering::Less, + 0 => Ordering::Equal, + 1 => Ordering::Greater, + _ => throw_str("unreachable"), + } + } +} + +impl TryFrom for Key { + type Error = errors::NumberIsNan; + + fn try_from(input: f64) -> Result { + if input.is_nan() { + Err(errors::NumberIsNan) + } else { + Ok(Key(JsValue::from_f64(input))) + } + } +} + +impl TryFrom<&js_sys::Date> for Key { + type Error = errors::NumberIsNan; + + fn try_from(input: &js_sys::Date) -> Result { + if input.value_of().is_nan() { + Err(errors::NumberIsNan) + } else { + Ok(Key(JsValue::from(input))) + } + } +} + +impl From<&str> for Key { + fn from(input: &str) -> Self { + Key(JsValue::from_str(input)) + } +} + +impl From<&js_sys::ArrayBuffer> for Key { + fn from(input: &js_sys::ArrayBuffer) -> Self { + Key(JsValue::from(input)) + } +} + +// TODO From +// TODO figure out how to implement for &[T] where Key: (Try)From - we should be able to build +// the array on the fly and save the user having to build the `[Key]`. + +impl From<&[Key]> for Key { + fn from(input: &[Key]) -> Self { + let array = js_sys::Array::new(); + for el in input.iter() { + array.push(&el.0); + } + Key(array.into()) + } +} + +/// A query to filter a sequence of records (to those that match the query). +/// +/// It is either no restriction (`Query::ALL`), a specific value of the `Key`, or a range of +/// `Key` values. +#[derive(Debug)] +pub struct Query { + /// `None` means `all records` + pub(crate) inner: Option, +} + +impl Query { + /// A special range that includes all records in a store/index. + pub const ALL: Self = Self { inner: None }; + + /// Create a new `Query`. + fn new(inner: Result) -> Self { + Self { + inner: Some(inner.expect_throw("keyrange error not caught (should be unreachable)")), + } + } +} + +impl From> for Query { + fn from(range: Range) -> Self { + if range.start >= range.end { + throw_str("lower bound was >= upper bound (the range is empty)"); + } + Self::new(IdbKeyRange::bound_with_lower_open_and_upper_open( + &range.start.0, + &range.end.0, + false, + true, + )) + } +} + +impl From> for Query { + fn from(range: RangeInclusive) -> Self { + if range.start() > range.end() { + throw_str("lower bound was > upper bound (the range is empty)"); + } + Self::new(IdbKeyRange::bound(&range.start().0, &range.end().0)) + } +} + +impl From> for Query { + fn from(range: RangeFrom) -> Self { + Self::new(IdbKeyRange::lower_bound(&range.start.0)) + } +} + +impl From> for Query { + fn from(range: RangeTo) -> Self { + Self::new(IdbKeyRange::upper_bound_with_open(&range.end.0, true)) + } +} + +impl From> for Query { + fn from(range: RangeToInclusive) -> Self { + Self::new(IdbKeyRange::upper_bound(&range.end.0)) + } +} + +impl From for Query { + fn from(key: Key) -> Self { + Self::new(IdbKeyRange::only(&key.0)) + } +} + +impl From for Query { + fn from(_: RangeFull) -> Self { + Self::ALL + } +} diff --git a/crates/storage/src/indexed_db/mod.rs b/crates/storage/src/indexed_db/mod.rs new file mode 100644 index 00000000..bb73259f --- /dev/null +++ b/crates/storage/src/indexed_db/mod.rs @@ -0,0 +1,568 @@ +//! A futures-based wrapper around indexed DB. +use futures::stream::Stream; +use gloo_events::{EventListener, EventListenerOptions}; +use gloo_utils::window; +use std::{ + future::Future, + ops::Deref, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; +use wasm_bindgen::{prelude::*, throw_str, JsCast, UnwrapThrowExt}; +use web_sys::{ + DomException, IdbDatabase, IdbFactory, IdbObjectStoreParameters, IdbOpenDbRequest, IdbRequest, + IdbRequestReadyState, IdbTransaction, IdbTransactionMode, IdbVersionChangeEvent, +}; + +mod util; +pub use util::{StringList, StringListIter}; +mod object_store; +pub use object_store::{ + CreateIndex, ObjectStoreDuringUpgrade, ObjectStoreReadOnly, ObjectStoreReadWrite, OpenCursor, +}; +mod key; +pub use key::{IntoKeyPath, Key, KeyPath, Query}; +mod transaction; +pub use transaction::{TransactionDuringUpgrade, TransactionReadOnly, TransactionReadWrite}; +mod index; +pub use index::Index; +mod cursor; +pub use cursor::{Cursor, CursorDirection, CursorStream, KeyCursor}; +pub mod errors; + +/// Marker type for read-only. +#[derive(Debug)] +pub enum ReadOnly {} +/// Marker type for read-write. +#[derive(Debug)] +pub enum ReadWrite {} +/// Marker type for upgrade. +#[derive(Debug)] +pub enum Upgrade {} + +fn indexed_db() -> Option { + window().indexed_db().ok().flatten() +} + +/// Checks if indexed db is supported in the current context. +pub fn is_supported() -> bool { + indexed_db().is_some() +} + +/// Delete an indexed db database. +/// +/// The database will be deleted whether the future is polled or not. +pub fn delete_database( + name: &str, + error_on_block: bool, +) -> impl Future> { + let request = indexed_db() + .ok_or(errors::DeleteDatabaseError::IndexedDbUnsupported) + .and_then(|factory| { + factory + .delete_database(name) + .map_err(errors::DeleteDatabaseError::from) + }); + + async move { + OpenDbRequest::new(request?, error_on_block) + .await + .map(|_| ()) + .map_err(errors::DeleteDatabaseError::from) + } +} + +/// An indexeddb database +#[derive(Debug)] +pub struct Database { + inner: IdbDatabase, +} + +impl Database { + // TODO should we handle the 'block' event? It doesn't mean failure, just + // that the future will not complete until other db instances are closed. + // Maybe promote to an error? + /// Open a database. + /// + /// - The version must not be `0`. + /// - If error_on_block is `true`, then if the request would block, it instead returns + /// `OpenDatabaseError::WouldBlock`. + /// + /// If you need to use async functions in `upgrade_fn`, then use + /// [`wasm_bindgen_futures::spawn_local`] to spawn a future. As long as at least one db + /// operation is alive across all breakpoints, the transaction will not commit (it will + /// auto-commit the first chance it gets). + pub async fn open( + name: &str, + version: u32, + mut upgrade_fn: impl FnMut(DatabaseDuringUpgrade) + 'static, + error_on_block: bool, + ) -> Result { + if version == 0 { + return Err(errors::OpenDatabaseError::InvalidVersion); + } + let request = indexed_db() + .ok_or(errors::OpenDatabaseError::IndexedDbUnsupported)? + .open_with_u32(name, version) + .expect_throw("Database::open"); + + // Listeners keep the closures alive unless dropped, in which case they are cleaned up. + // Using `let _ = ...` would immediately drop the closure meaning it is not run. + let _upgrade_listener = EventListener::new(&request, "upgradeneeded", { + let request = request.clone(); + move |event| { + let event = event + .dyn_ref::() + .expect_throw("IdbVersionChangeEvent dyn_into"); + let old_version = event.old_version() as u32; + // newVersion is only null in a delete transation (which we know isn't happening + // here) + let new_version = event.new_version().unwrap_throw() as u32; + let db = request + .result() + .expect_throw("IdbOpenDatabaseRequest::result") + .dyn_into::() + .expect_throw("IdbDatabase dyn_into"); + + // This seems to be the way to get a transation + let transaction = request.transaction().expect_throw("request.transaction()"); + let transaction = TransactionDuringUpgrade::new(transaction); + + upgrade_fn(DatabaseDuringUpgrade { + old_version, + new_version, + db: Database { inner: db }, + transaction: &transaction, + }); + } + }); + + let result = OpenDbRequest::new(request, error_on_block) + .await + .map_err(errors::OpenDatabaseError::from)?; + let inner = result + .dyn_into::() + .expect_throw("dyn_into IdbDatabase"); + Ok(Database { inner }) + } + + /// Get the name of the db + pub fn name(&self) -> String { + self.inner.name() + } + + /// Get a list of all the object store names for the database. + /// + /// # Examples + /// + /// Does the db contain a "test" object store? + /// ```no_run + /// let contains_test: bool = db.object_store_names().contains("test").unwrap_throw(); + /// ``` + /// + /// Collect names into a `Vec`.. + /// ```no_run + /// db.object_store_names().into_iter().collect::>() + /// ``` + pub fn object_store_names(&self) -> StringList { + StringList::new(self.inner.object_store_names()) + } + + /// Open a transaction with access to the given stores in "readwrite" mode. + pub fn transaction_readwrite( + &self, + stores: &[impl AsRef], + ) -> Result { + let array = js_sys::Array::new(); + for store in stores { + array.push(&JsValue::from_str(store.as_ref())); + } + self.transaction_inner(&array, IdbTransactionMode::Readwrite) + .map(TransactionReadWrite::new) + } + + /// Open a transaction with access to the given stores in "readonly" mode. + pub fn transaction_readonly( + &self, + stores: &[impl AsRef], + ) -> Result { + let array = js_sys::Array::new(); + for store in stores { + array.push(&JsValue::from_str(store.as_ref())); + } + self.transaction_inner(&array, IdbTransactionMode::Readonly) + .map(TransactionReadOnly::new) + } + + fn transaction_inner( + &self, + stores: &JsValue, + mode: IdbTransactionMode, + ) -> Result { + self.inner + .transaction_with_str_sequence_and_mode(stores, mode) + .map_err(errors::StartTransactionError::from) + } +} + +impl Drop for Database { + fn drop(&mut self) { + self.inner.close() + } +} + +/// Provides access to the database during an update event. +/// +/// Use this object to create/delete object stores and indexes. You can also get access to the +/// underlying DB, but note that any transactions created here will run after the database upgrade +/// has completed. +#[derive(Debug)] +pub struct DatabaseDuringUpgrade<'trans> { + old_version: u32, + new_version: u32, + db: Database, + transaction: &'trans TransactionDuringUpgrade, +} + +impl<'trans> DatabaseDuringUpgrade<'trans> { + // these methods exist to make the values read-only. + /// The database version at the start of this upgrade. + pub fn old_version(&self) -> u32 { + self.old_version + } + + /// The database version at the end of this upgrade. + pub fn new_version(&self) -> u32 { + self.new_version + } + + /// Create a new object store in the database + /// + /// # Example + /// + /// ```no_run + /// db.create_object_store("test") + /// .auto_increment(false) + /// .key_path("key.path") + /// .build() + /// ``` + pub fn create_object_store<'a>(&'a self, name: &'a str) -> CreateObjectStore<'a> { + CreateObjectStore { + name, + params: IdbObjectStoreParameters::new(), + db: &self.db.inner, + } + } + /// Delete an object store from the database + /// + /// Returns `true` if an object store was deleted or `false` if no object store with + /// that name existed. Errors if the database has been deleted since the update started. + pub fn delete_object_store(&self, name: &str) -> Result<(), errors::DeleteObjectStoreError> { + self.db + .inner + .delete_object_store(name) + .map_err(errors::DeleteObjectStoreError::from) + } + + /// Get the database upgrade transaction. + /// + /// Use this method rather than `Database::start_*_transaction` if you want to rename or add indexes to + /// already existing object stores. + pub fn upgrade_transaction(&self) -> &'trans TransactionDuringUpgrade { + self.transaction + } +} + +impl<'trans> Deref for DatabaseDuringUpgrade<'trans> { + type Target = Database; + + fn deref(&self) -> &Database { + &self.db + } +} + +/// Builder struct to create object stores +#[derive(Debug)] +pub struct CreateObjectStore<'a> { + name: &'a str, + params: IdbObjectStoreParameters, + db: &'a IdbDatabase, +} + +impl<'a> CreateObjectStore<'a> { + /// If `true`, the object store has a + /// [key generator](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#key_generator). + /// Defaults to `false`. + pub fn auto_increment(mut self, auto_increment: bool) -> Self { + self.params.auto_increment(auto_increment); + self + } + + /// The [key path] to be used by the new object store. If empty or not specified, the object + /// store is created without a key path and uses [out-of-line keys]. You can also pass in an + /// array as a `key_path`. + /// + /// [key path]: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#key_path + /// [out-of-line keys]: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#out-of-line_key + pub fn key_path(mut self, key_path: impl IntoKeyPath) -> Self { + self.params.key_path(Some(&key_path.into_jsvalue())); + self + } + + /// Actually create the object store using the configured builder. + /// + /// # Panics + /// + /// This function will panic if `auto_increment` is set to `false` (the default), and + /// `key_path` is empty or not set. + pub fn build(self) -> Result { + self.db + .create_object_store_with_optional_parameters(self.name, &self.params) + .map(ObjectStoreDuringUpgrade::new) + .map_err(errors::CreateObjectStoreError::from) + } +} + +/// Wrapper around IdbRequest that implements `Future`. +struct Request { + inner: IdbRequest, + bubble_errors: bool, + success_listener: Option, + error_listener: Option, +} + +impl Request { + fn new(inner: IdbRequest, bubble_errors: bool) -> Self { + Self { + inner, + bubble_errors, + success_listener: None, + error_listener: None, + } + } +} + +impl Future for Request { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.inner.ready_state() { + IdbRequestReadyState::Pending => { + if self.success_listener.is_none() { + self.success_listener = Some(EventListener::once(&self.inner, "success", { + let waker = cx.waker().clone(); + move |_| waker.wake() + })) + } else { + throw_str("success_listener") + } + if self.error_listener.is_none() { + let opts = if self.bubble_errors { + EventListenerOptions::enable_prevent_default() + } else { + EventListenerOptions::default() + }; + self.error_listener = Some(EventListener::once_with_options( + &self.inner, + "error", + opts, + { + let waker = cx.waker().clone(); + let bubble_errors = self.bubble_errors; + move |event| { + waker.wake(); + if !bubble_errors { + event.prevent_default(); + } + } + }, + )) + } else { + throw_str("error_listener") + } + Poll::Pending + } + IdbRequestReadyState::Done => { + if let Some(error) = self.inner.error().expect_throw("get error") { + Poll::Ready(Err(error)) + } else { + // no error = success + Poll::Ready(Ok(self.inner.result().expect_throw("get result"))) + } + } + _ => throw_str("unknown ReadyState"), + } + } +} + +/// Wrapper around IdbRequest that implements `Future`. +struct OpenDbRequest { + inner: IdbOpenDbRequest, + error_on_block: bool, + success_listener: Option, + error_listener: Option, + blocked_listener: Option, + blocked: Arc, +} + +impl OpenDbRequest { + fn new(inner: IdbOpenDbRequest, error_on_block: bool) -> Self { + Self { + inner, + error_on_block, + success_listener: None, + error_listener: None, + blocked_listener: None, + blocked: Arc::new(AtomicBool::new(false)), + } + } +} + +impl Future for OpenDbRequest { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.blocked.load(Ordering::SeqCst) { + // return error + return Poll::Ready(Err(DomException::new_with_message_and_name( + "transaction would block", + "TransactionWouldBlock", + ) + .expect_throw("DomException"))); + } + + match self.inner.ready_state() { + IdbRequestReadyState::Pending => { + if self.success_listener.is_none() { + self.success_listener = Some(EventListener::once(&self.inner, "success", { + let waker = cx.waker().clone(); + move |_| waker.wake() + })) + } else { + throw_str("success_listener") + } + if self.error_listener.is_none() { + self.error_listener = Some(EventListener::once(&self.inner, "error", { + let waker = cx.waker().clone(); + move |_| waker.wake() + })) + } else { + throw_str("error_listener") + } + if self.error_on_block { + if self.blocked_listener.is_none() { + self.blocked_listener = Some(EventListener::once(&self.inner, "blocked", { + let blocked = self.blocked.clone(); + let waker = cx.waker().clone(); + move |_| { + blocked.store(true, Ordering::SeqCst); + waker.wake(); + } + })) + } else { + throw_str("blocked_lsitener") + } + } + Poll::Pending + } + IdbRequestReadyState::Done => { + if let Some(error) = self.inner.error().expect_throw("error") { + Poll::Ready(Err(error)) + } else { + // no error = success + Poll::Ready(Ok(self.inner.result().expect_throw("result"))) + } + } + _ => throw_str("ready state"), + } + } +} + +/// Wrapper for IDBRequest where the success callback is run multiple times. +// TODO If a task is woken up, does `wasm_bindgen_futures` try to progress the future in the same +// microtask or a separate one? This will impact whether I need to have space for more than one +// result at a time. +#[derive(Debug)] +pub struct StreamingRequest { + inner: IdbRequest, + bubble_errors: bool, + success_listener: Option, + error_listener: Option, +} + +impl StreamingRequest { + fn new(inner: IdbRequest, bubble_errors: bool) -> Self { + Self { + inner, + bubble_errors, + success_listener: None, + error_listener: None, + } + } +} + +impl Stream for StreamingRequest { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.inner.ready_state() { + IdbRequestReadyState::Pending => { + if self.success_listener.is_none() { + // First call - setup + self.success_listener = Some(EventListener::new(&self.inner, "success", { + let waker = cx.waker().clone(); + move |_| { + let waker = waker.clone(); + waker.wake() + } + })); + + // omit the error.is_none check to save a branch. + let opts = if self.bubble_errors { + EventListenerOptions::enable_prevent_default() + } else { + EventListenerOptions::default() + }; + self.error_listener = Some(EventListener::new_with_options( + &self.inner, + "error", + opts, + { + let waker = cx.waker().clone(); + let bubble_errors = self.bubble_errors; + move |event| { + let waker = waker.clone(); + waker.wake(); + if !bubble_errors { + event.prevent_default(); + } + } + }, + )); + } + + Poll::Pending + } + IdbRequestReadyState::Done => { + if let Some(error) = self.inner.error().expect_throw("get error") { + Poll::Ready(Some(Err(error))) + } else { + // no error = success + // if the result is null, there won't be any more entries (at least for + // IDBCursor, which I think is the only case a request is re-used) + let result = self.inner.result().expect_throw("get result"); + if result.is_null() || result.is_undefined() { + Poll::Ready(None) + } else { + Poll::Ready(Some(Ok(result))) + } + } + } + _ => throw_str("unreachable"), + } + } +} diff --git a/crates/storage/src/indexed_db/object_store.rs b/crates/storage/src/indexed_db/object_store.rs new file mode 100644 index 00000000..84fa9d27 --- /dev/null +++ b/crates/storage/src/indexed_db/object_store.rs @@ -0,0 +1,421 @@ +// NOTE: all transaction operations must be started on the *same tick* (i.e. not in an async block) +// otherwise with transaction will auto-commit before the operation is started. +use super::{ + errors, CursorDirection, CursorStream, Index, IntoKeyPath, Key, KeyPath, Query, Request, + StreamingRequest, StringList, TransactionDuringUpgrade, +}; +use futures::{ + future::{ready, Either}, + FutureExt, +}; +use serde::{de::DeserializeOwned, Serialize}; +use std::{convert::TryFrom, future::Future, ops::Deref}; +use wasm_bindgen::{prelude::*, throw_str, JsCast, UnwrapThrowExt}; +use web_sys::{IdbIndexParameters, IdbObjectStore}; + +/// An object store during a database upgrade. +/// +/// Note that object stores are always sorted with their key values ascending. +#[derive(Debug)] +pub struct ObjectStoreDuringUpgrade { + inner: ObjectStoreReadWrite, +} + +impl ObjectStoreDuringUpgrade { + pub(crate) fn new(inner: IdbObjectStore) -> Self { + Self { + inner: ObjectStoreReadWrite::new(inner), + } + } + + /// Changes the object store's name. + /// + /// # Panics + /// + /// Currently this method will panic on error. If/when [this wasm-bindgen patch](https://github.com/rustwasm/wasm-bindgen/pull/2852) + /// lands errors will be returned instead. Because of the return type, the change will be + /// backwards-compatible. + pub fn set_name(&self, new_name: &str) -> Result<(), errors::SetNameError> { + self.raw().set_name(new_name); + Ok(()) + } + + /// Create a new index for the object store. + pub fn create_index<'b, K: IntoKeyPath>( + &'b self, + name: &'b str, + key_path: K, + ) -> CreateIndex<'b, K> { + CreateIndex { + store: self.raw(), + name, + key_path, + params: IdbIndexParameters::new(), + } + } + + /// Delete the index with the given name. + pub fn delete_index(&self, name: &str) -> Result<(), errors::DeleteIndexError> { + self.raw() + .delete_index(name) + .map_err(errors::DeleteIndexError::from) + } +} + +impl Deref for ObjectStoreDuringUpgrade { + type Target = ObjectStoreReadWrite; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// A builder object for creating an index. +/// +/// Once you have set the options you require, call `build` to run the operation. +#[derive(Debug)] +pub struct CreateIndex<'a, K: IntoKeyPath> { + store: &'a IdbObjectStore, + name: &'a str, + key_path: K, + params: IdbIndexParameters, +} + +impl<'a, K: IntoKeyPath> CreateIndex<'a, K> { + /// If `true`, the index will not allow duplicate values for a single key. + pub fn unique(mut self, yes: bool) -> Self { + self.params.unique(yes); + self + } + + /// If `true`, the index will add an entry in the index for each array element when the + /// *keyPath* resolves to an `Array`. If `false`, it will add one single entry containing the + /// `Array`. + pub fn multi_entry(mut self, yes: bool) -> Self { + self.params.multi_entry(yes); + self + } + + // TODO potentially add `locale` in the future. + + /// + /// Note that object stores are always sorted with their key values ascending. + /// Run the create index operation. + /// + /// If you don't call this method, then the index won't be created. + pub fn build(self) -> Result { + self.store + .create_index_with_str_sequence_and_optional_parameters( + self.name, + &self.key_path.into_jsvalue(), + &self.params, + ) + .map(Index::new) + .map_err(errors::CreateIndexError::from) + } +} + +/// An object store during a `readwrite` transaction. +/// +/// Note that object stores are always sorted with their key values ascending. +#[derive(Debug)] +pub struct ObjectStoreReadWrite { + inner: ObjectStoreReadOnly, +} + +impl ObjectStoreReadWrite { + pub(crate) fn new(inner: IdbObjectStore) -> Self { + Self { + inner: ObjectStoreReadOnly::new(inner), + } + } + + /// Add an object to the database. + /// + /// This method returns a future, but always tries to add the object irrespective of whether + /// the future is ever polled. If `bubble_errors = true` any errors returned here will also + /// cause the transaction to abort. + pub fn add_raw( + &self, + value: &JsValue, + key: Option, + bubble_errors: bool, + ) -> impl Future> { + let request = if let Some(key) = key { + self.raw().add_with_key(value, &key.0) + } else { + self.raw().add(value) + }; + + async move { + let request = match request { + Ok(request) => request, + Err(e) => return Err(errors::AddError::from(e)), + }; + match Request::new(request, bubble_errors).await { + Ok(_) => Ok(()), + Err(e) => Err(errors::AddError::from(e)), + } + } + } + + /// Add an arbitrary object to the database using serde to serialize it to a JsValue. + pub fn add( + &self, + value: &(impl Serialize + ?Sized), + key: Option, + bubble_errors: bool, + ) -> impl Future> { + // TODO handle errors + let value = serde_wasm_bindgen::to_value(value).unwrap(); + let request = self.add_raw(&value, key, bubble_errors); + async move { request.await } + } + + /// Delete all objects in this object store. + /// + /// This method returns a future, but always tries to add the object irrespective of whether + /// the future is ever polled. + /// + /// If `bubble_errors = true` then an error here will also cause the transaction to abort, + /// whether the error is handled or not. + pub fn clear( + &self, + bubble_errors: bool, + ) -> impl Future> { + let request = self.raw().clear(); + + async move { + Request::new(request.map_err(errors::ClearError::from)?, bubble_errors) + .await + .map(|_| ()) + .map_err(errors::ClearError::from) + } + } + + /// Delete records from the store that match the given key. + // TODO delete_range function + pub async fn delete( + &self, + key: impl Into, + bubble_errors: bool, + ) -> Result<(), errors::DeleteError> { + // give the optimizer the choice of inlining this function or not (minus generics) + async fn delete_inner( + this: &ObjectStoreReadWrite, + key: Key, + bubble_errors: bool, + ) -> Result<(), errors::DeleteError> { + let request = this + .raw() + .delete(&key.0) + .map_err(errors::DeleteError::from)?; + Request::new(request, bubble_errors) + .await + .map(|_| ()) + .map_err(errors::DeleteError::from) + } + + delete_inner(self, key.into(), bubble_errors).await + } +} +impl Deref for ObjectStoreReadWrite { + type Target = ObjectStoreReadOnly; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// An object store during a `readonly` transaction. +/// +/// Note that object stores are always sorted with their key values ascending. To iterate in the +/// descending direction use `CursorDirection::Previous`. +#[derive(Debug)] +pub struct ObjectStoreReadOnly { + inner: IdbObjectStore, + // TODO cache key path so we can provide better errors when key not supplied? +} + +impl ObjectStoreReadOnly { + pub(crate) fn new(inner: IdbObjectStore) -> Self { + Self { inner } + } + + fn raw(&self) -> &IdbObjectStore { + &self.inner + } + + /// Get whether this object store uses an auto-incrementing key + pub fn auto_increment(&self) -> bool { + self.raw().auto_increment() + } + + /// Get a list containing all the names of indices on this object store. + pub fn index_names(&self) -> StringList { + StringList::new(self.raw().index_names()) + } + + /// Get the key path for the object store. + // Note: return value should be either null, a DOMString, or a sequence (from w3 + // spec) + pub fn key_path(&self) -> KeyPath { + let key_path = self.raw().key_path().unwrap_throw(); + if key_path.is_null() { + KeyPath::None + } else if let Some(key_path) = key_path.as_string() { + KeyPath::String(key_path) + } else { + let key_path = key_path.unchecked_into::(); + + let mut out = vec![]; + for val in &key_path { + out.push(val.unwrap_throw().as_string().unwrap_throw()); + } + KeyPath::Sequence(out) + } + } + + /// The name of the object store. + pub fn name(&self) -> String { + self.raw().name() + } + + /// Count the number of records in the object store. + // TODO optional query argument - `count_query` function? + pub async fn count(&self) -> Result { + let result = Request::new(self.raw().count().map_err(errors::CountError::from)?, false) + .await + .map_err(errors::CountError::from)?; + let result = result.as_f64().expect_throw("unreachable"); + // From reading MDN it seems indexeddb cannot handle counts more than 2^32-1. + if result <= u32::MAX.into() { + Ok(result as u32) + } else { + throw_str("unreachable") + } + } + + /// Get an object from the object store by searching for the given key. + pub fn get(&self, key: K) -> impl Future, errors::GetError>> + where + Key: TryFrom, + V: DeserializeOwned, + { + fn get_inner( + this: &ObjectStoreReadOnly, + key: Key, + ) -> impl Future> { + let request = this.raw().get(&key.0).map_err(errors::GetError::from); + async move { + let request = Request::new(request?, false); + request.await.map_err(errors::GetError::from) + } + } + + let key = match Key::try_from(key) { + Ok(key) => key, + Err(_) => return Either::Left(ready(Err(errors::GetError::InvalidKey))), + }; + Either::Right(get_inner(self, key).map(|output| { + serde_wasm_bindgen::from_value(output?).map_err(errors::GetError::Deserialize) + })) + } + + /// Get an object from the object store by searching for the given key. + /// + /// The result will be need to be deserialized from a javascript array, so you should use a + /// type like `Vec` to deserialize into. The reason we don't return a `Vec` is that you + /// might want to use a different collection, for example `im::Vector` from the + /// [`im`](https://crates.io/crates/im) crate, or `futures_signals::SignalVec::MutableVec` from + /// the [`futures_signals`](https://crates.io/crates/futures_signals) crate + pub async fn get_all(&self) -> Result + where + V: DeserializeOwned, + { + Request::new(self.raw().get_all().map_err(errors::GetError::from)?, false) + .await + .map_err(errors::GetError::from) + .and_then(|val| { + serde_wasm_bindgen::from_value(val).map_err(errors::GetError::Deserialize) + }) + } + + /// Open a cursor into the object store. + /// + /// This returns a builder - call `build` on it to submit the request. Defaults to iterating + /// over all values in the store, going forwards. + /// + /// # Examples + /// + /// > For all examples assume `store` is an open object store + /// + /// Iterate over all records in ascending order (like [`get_all`], but doesn't require holding + /// all records in memory at once) + /// + /// ```no_run + /// use futures::StreamExt; + /// + /// let mut iter = store.open_cursor().build(); + /// while let Some(object) = store.next().await { + /// // do something with the object + /// } + /// ``` + pub fn open_cursor(&self) -> OpenCursor { + OpenCursor::new(self) + } +} + +/// Builder struct to open a cursor +#[derive(Debug)] +pub struct OpenCursor<'a> { + store: &'a ObjectStoreReadOnly, + query: Query, + direction: CursorDirection, + bubble_errors: bool, +} + +impl<'store> OpenCursor<'store> { + fn new(store: &'store ObjectStoreReadOnly) -> Self { + Self { + store, + query: Query::ALL, + direction: CursorDirection::Next, + bubble_errors: true, + } + } + + /// Set the query used to filter the output. + /// + /// Records will be returned if the record's key satisfies the query, and skipped otherwise. + /// The query can be a key, or a range of keys. Note that the conversions into a query can + /// panic if the range is empty (either the end is before the start, or the end equals the + /// start and the range doesn't include the upper bound (e.g. for `0..n`)) + pub fn query_key(mut self, query: impl Into) -> Self { + self.query = query.into(); + self + } + + /// Set the direction the cursor should traverse the results in. + /// + /// Objects are always stored in ascending order by key, so + pub fn direction(mut self, direction: CursorDirection) -> Self { + self.direction = direction; + self + } + + /// Execute the request and return an object implementing `Stream>`. + pub fn build(self) -> Result { + let store = self.store.raw(); + let dir = self.direction.into(); + let request = match self.query.inner.as_ref() { + Some(range) => store.open_cursor_with_range_and_direction(&range, dir), + None => store.open_cursor_with_range_and_direction(&JsValue::UNDEFINED, dir), + } + .map_err(errors::GetError::from)?; + Ok(CursorStream::new(StreamingRequest::new( + request, + self.bubble_errors, + ))) + } +} diff --git a/crates/storage/src/indexed_db/transaction.rs b/crates/storage/src/indexed_db/transaction.rs new file mode 100644 index 00000000..61ec0f7e --- /dev/null +++ b/crates/storage/src/indexed_db/transaction.rs @@ -0,0 +1,161 @@ +use super::{errors, ObjectStoreDuringUpgrade, ObjectStoreReadOnly, ObjectStoreReadWrite}; +use js_sys::{Object, Reflect}; +use once_cell::sync::Lazy; +use std::ops::Deref; +use wasm_bindgen::{prelude::*, throw_str, JsCast}; +use web_sys::{IdbObjectStore, IdbTransaction}; + +/// An in-progress database upgrade transaction +/// +/// Please do not stash the transaction. Once our code yields (e.g. over an await point that +/// doesn't involve a database method) the transaction will autocommit, and further attempts to use +/// it will return an error. +#[derive(Debug)] +pub struct TransactionDuringUpgrade { + inner: TransactionReadWrite, +} + +impl TransactionDuringUpgrade { + pub(crate) fn new(inner: IdbTransaction) -> Self { + Self { + inner: TransactionReadWrite::new(inner), + } + } + + /// Fetch an object store + /// + /// Note this deliberately shadows [`TransactionReadWrite::object_store`], providing access to + /// it through `ObjectStoreDuringUpgrade::deref`. + pub fn object_store<'trans>( + &'trans self, + name: &str, + ) -> Result { + object_store(self.raw(), name).map(ObjectStoreDuringUpgrade::new) + } +} + +impl Deref for TransactionDuringUpgrade { + type Target = TransactionReadWrite; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// An in-progress database transaction +/// +/// Please do not stash the transaction. Once our code yields (e.g. over an await point) +#[derive(Debug)] +pub struct TransactionReadWrite { + inner: TransactionReadOnly, +} + +impl TransactionReadWrite { + pub(crate) fn new(inner: IdbTransaction) -> Self { + Self { + inner: TransactionReadOnly::new(inner), + } + } + + /// Fetch an object store + /// + /// Note this deliberately shadows [`TransactionReadOnly::object_store`], providing access to + /// it through `Deref`. + pub fn object_store( + &self, + name: &str, + ) -> Result { + object_store(self.raw(), name).map(ObjectStoreReadWrite::new) + } +} + +impl Deref for TransactionReadWrite { + type Target = TransactionReadOnly; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// An in-progress database transaction +/// +/// Please do not stash the transaction. Once our code yields (e.g. over an await point) +#[derive(Debug)] +pub struct TransactionReadOnly { + inner: IdbTransaction, +} + +impl TransactionReadOnly { + pub(crate) fn new(inner: IdbTransaction) -> Self { + Self { inner } + } + + fn raw(&self) -> &IdbTransaction { + &self.inner + } + + /// Fetch an object store + pub fn object_store( + &self, + name: &str, + ) -> Result { + object_store(self.raw(), name).map(ObjectStoreReadOnly::new) + } + + /// This function commits the transaction if supported. + /// + /// Any further use of the transaction (e.g. through object stores) will return errors. + // if `IDBTransaction.prototype.commit` is present. + pub fn commit(&self) { + if *SUPPORTS_COMMIT { + let t = self.inner.unchecked_ref::(); + t.commit(); + } + } +} + +impl Drop for TransactionReadOnly { + fn drop(&mut self) { + // indexeddb already does auto-commit. This tells it we've done so it can potentially + // commit the transaction earlier + // TODO if we re-enable this we need to force users to keep the transaciton alive (e.g. by + // making object stores borrow from it). + //self.commit(); + } +} + +fn object_store( + trans: &IdbTransaction, + name: &str, +) -> Result { + trans + .object_store(name) + .map_err(errors::ObjectStoreError::from) +} + +// Optional support for transaction.commit +// TODO remove logging +static SUPPORTS_COMMIT: Lazy = Lazy::new(|| { + if let Ok(ty) = Reflect::get(&gloo_utils::window(), &"IDBTransaction".into()) { + if let Ok(proto) = Reflect::get(&ty, &"prototype".into()) { + if let Ok(method) = Reflect::get(&proto, &"commit".into()) { + if !(method.is_null() || method.is_undefined()) { + web_sys::console::log_1(&"`IDBTransaction.prototype.commit` supported".into()); + return true; + } + } + } + } + web_sys::console::log_1(&"`IDBTransaction.prototype.commit` unsupported".into()); + false +}); + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(extends = ::js_sys::Object, js_name = IDBTransaction)] + type SupportsCommit; + + // this doesn't seem to be in web_sys, perhaps because it's new. + #[wasm_bindgen(structural, method, js_class = "IDBTransaction", js_name = commit)] + fn commit(this: &SupportsCommit); +} diff --git a/crates/storage/src/indexed_db/util.rs b/crates/storage/src/indexed_db/util.rs new file mode 100644 index 00000000..4c5508c7 --- /dev/null +++ b/crates/storage/src/indexed_db/util.rs @@ -0,0 +1,78 @@ +use wasm_bindgen::UnwrapThrowExt; +use web_sys::DomStringList; + +/// A wrapper around [`web_sys::DomStringList`] for easy iteration. +#[derive(Debug)] +pub struct StringList { + inner: DomStringList, +} + +impl StringList { + pub(crate) fn new(inner: DomStringList) -> Self { + Self { inner } + } + + /// The number of strings in this list. + pub fn len(&self) -> u32 { + self.inner.length() + } + + /// Is this list empty? + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Does this list contain `value`? + pub fn contains(&self, value: &str) -> bool { + self.inner.contains(value) + } + + /// Get the raw [`DomStringList`][web_sys::DomStringList]. + pub fn into_inner(self) -> DomStringList { + self.inner + } +} + +impl IntoIterator for StringList { + type Item = String; + type IntoIter = StringListIter; + fn into_iter(self) -> Self::IntoIter { + StringListIter::new(self.inner) + } +} + +/// An iterator over the `String` contents of `StringList` +#[derive(Debug)] +pub struct StringListIter { + idx: u32, + /// Cache length to avoid going through JS quite so many times. + length: u32, + inner: DomStringList, +} + +impl StringListIter { + fn new(inner: DomStringList) -> Self { + Self { + idx: 0, + length: inner.length(), + inner, + } + } + + /// Get the original `StringList` back. + pub fn into_inner(self) -> StringList { + StringList { inner: self.inner } + } +} + +impl Iterator for StringListIter { + type Item = String; + fn next(&mut self) -> Option { + if self.idx >= self.length { + return None; + } + let out = self.inner.get(self.idx).unwrap_throw(); + self.idx += 1; + Some(out) + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index ddbbf412..067bbef0 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -15,6 +15,8 @@ use errors::StorageError; use gloo_utils::window; use serde_json::{Map, Value}; +#[macro_use] +mod macros; pub mod errors; pub mod indexed_db; mod local_storage; diff --git a/crates/storage/src/macros.rs b/crates/storage/src/macros.rs new file mode 100644 index 00000000..5694a447 --- /dev/null +++ b/crates/storage/src/macros.rs @@ -0,0 +1,25 @@ +/// A helper macro to generate `from_jsvalue` impls for errors. +/// +/// Expects one variant to look like `Unexpected(String)`. +macro_rules! error_from_jsvalue { + ($name:ident {$($str:expr => $variant:ident),* $(,)? }) => { + impl From<::web_sys::DomException> for $name { + fn from(error: ::web_sys::DomException) -> Self { + let name = error.name(); + match name.as_str() { + $($str => Self::$variant,)* + _ => Self::Unexpected(error.message()), + } + } + } + impl From<::wasm_bindgen::JsValue> for $name { + fn from(raw: ::wasm_bindgen::JsValue) -> Self { + let error = match ::wasm_bindgen::JsCast::dyn_into::<::web_sys::DomException>(raw) { + Ok(error) => error, + Err(_) => return Self::Unexpected("".into()), + }; + Self::from(error) + } + } + }; +} diff --git a/crates/storage/tests/indexed_db.rs b/crates/storage/tests/indexed_db.rs index 8ef44fcc..b0c09d64 100644 --- a/crates/storage/tests/indexed_db.rs +++ b/crates/storage/tests/indexed_db.rs @@ -1,35 +1,72 @@ -use gloo_storage::indexed_db::{delete_db, Db, DbUpgrade, Error}; -use serde::Deserialize; -use wasm_bindgen::UnwrapThrowExt; +use gloo_storage::indexed_db::{delete_database, Database, DatabaseDuringUpgrade}; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen_test] async fn create_db() { - delete_db("dbname").await; - Db::open("dbname", 1, |_| ()).await.unwrap_throw(); + delete_database("create_db", true).await.unwrap(); + Database::open("create_db", 1, |_| (), true).await.unwrap(); } -fn db_upgrade(db: DbUpgrade) { - if db.old_version < 1 && db.new_version >= 1 { - db.create_object_store("name") - .auto_increment(true) - .key_path("id") - .build() - .unwrap_throw(); - } - if db.old_version < 2 && db.new_version >= 2 { - db.delete_object_store("name").unwrap_throw(); +#[wasm_bindgen_test] +async fn create_delete_object_store() { + fn db_upgrade(db: DatabaseDuringUpgrade) { + if db.old_version() < 1 && db.new_version() >= 1 { + db.create_object_store("name") + .auto_increment(true) + .key_path("id") + .build() + .unwrap(); + } + if db.old_version() < 2 && db.new_version() >= 2 { + db.delete_object_store("name").unwrap(); + } } + + delete_database("create_delete_object_store", false) + .await + .unwrap(); + let db = Database::open("create_delete_object_store", 1, db_upgrade, false) + .await + .unwrap(); + assert_eq!( + db.object_store_names().into_iter().collect::>(), + vec!["name"] + ); + drop(db); + let db = Database::open("create_delete_object_store", 2, db_upgrade, false) + .await + .unwrap(); + assert!(db.object_store_names().is_empty()); } #[wasm_bindgen_test] -async fn create_delete_object_store() { - delete_db("dbname").await; - let db = Db::open("dbname", 1, db_upgrade).await.unwrap_throw(); - assert_eq!(db.object_store_names_vec(), vec!["name"]); +async fn get_upgrade_transaction() { + fn db_upgrade(db: DatabaseDuringUpgrade) { + if db.old_version() < 1 && db.new_version() >= 1 { + db.create_object_store("name") + .auto_increment(true) + .key_path("id") + .build() + .unwrap(); + } + if db.old_version() < 2 && db.new_version() >= 2 { + let store = db.upgrade_transaction().object_store("name").unwrap(); + store.create_index("name", "name"); + } + } + delete_database("get_upgrade_transaction", false) + .await + .unwrap(); + let db = Database::open("get_upgrade_transaction", 1, db_upgrade, false) + .await + .unwrap(); drop(db); - let db = Db::open("dbname", 2, db_upgrade).await.unwrap_throw(); - assert!(db.object_store_names().length() == 0); + let _db = Database::open("get_upgrade_transaction", 2, db_upgrade, false) + .await + .unwrap(); } + +#[wasm_bindgen_test] +async fn object_store_methods() {} diff --git a/examples/storage/.gitignore b/examples/storage/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/examples/storage/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/examples/storage/Cargo.toml b/examples/storage/Cargo.toml new file mode 100644 index 00000000..43451c41 --- /dev/null +++ b/examples/storage/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "storage" +version = "0.1.0" +authors = ["Rust and WebAssembly Working Group"] +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +gloo = { path = "../..", features = ["futures"] } +wasm-bindgen = "0.2.54" +wasm-bindgen-futures = "0.4.4" +futures-util = "0.3" +console_error_panic_hook = "0.1.6" +futures-signals = { version = "0.3.24", features = ["serde"] } +dominator = { version = "0.5.26", package = "dominator2", features = ["smartstring"] } +serde = { version = "1", features = ["derive", "rc"] } +smartstring = { version = "1", features = ["serde"] } + +[dependencies.web-sys] +version = "0.3.19" +features = [ + "console", + "Window", + "Document", + "Element", + "Node", +] + +[workspace] diff --git a/examples/storage/README.md b/examples/storage/README.md new file mode 100644 index 00000000..5f0dae63 --- /dev/null +++ b/examples/storage/README.md @@ -0,0 +1,7 @@ +# Clock example + +This is a simple example showcasing the Gloo timers. + +First, [install wasm-pack](https://rustwasm.github.io/wasm-pack/installer/) if needed. + +Then build the clock example by running `wasm-pack build --target no-modules` and open your browser to load `index.html`. \ No newline at end of file diff --git a/examples/storage/index.html b/examples/storage/index.html new file mode 100644 index 00000000..e279810f --- /dev/null +++ b/examples/storage/index.html @@ -0,0 +1,15 @@ + + + + + + Storage + + +
+ + + diff --git a/examples/storage/src/lib.rs b/examples/storage/src/lib.rs new file mode 100644 index 00000000..b3fd540f --- /dev/null +++ b/examples/storage/src/lib.rs @@ -0,0 +1,175 @@ +use dominator::{events, html, text, text_signal, with_node, Dom}; +use futures_signals::{ + signal::{Mutable, SignalExt}, + signal_vec::{MutableVec, SignalVecExt}, +}; +use gloo::storage::indexed_db as idb; +use serde::{Deserialize, Serialize}; +use smartstring::alias::String as SmartString; +use std::sync::Arc; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::spawn_local; + +type ArcStr = Arc; + +#[wasm_bindgen(start)] +pub fn main() { + console_error_panic_hook::set_once(); + + let app = App::new(); + let document = web_sys::window().unwrap_throw().document().unwrap_throw(); + + let el = document.get_element_by_id("app").unwrap_throw(); + + // render the date, then set it to re-render every second. + spawn_local(use_db(el)); +} + +enum App { + Loading, + Loaded(AppState), + Error(String), +} + +impl App { + fn new() -> Mutable { + let this = Mutable::new(App::Loading); + spawn_local({ + let this = this.clone(); + async move { + let state = AppState::new().await; + this.set(App::Loaded(state)) + } + }); + this + } + + fn render_mutable(this: Mutable) -> Dom { + html!("div", { + .attr("id", "app") + .child_signal(this.signal_ref(|app| Some(App::render(app)))) + }) + } + + fn render(&self) -> Dom { + match self { + App::Loading => text("Loading"), + App::Loaded(state) => state.render(), + App::Error(msg) => text(&format!("Error: {}", msg)), + } + } +} + +struct AppState { + db: idb::Database, + user: Person, + people: MutableVec, + new_person: Mutable, +} + +impl AppState { + async fn new() -> Self { + let db = idb::Database::open( + "mydb", + 1, + |db| { + let store = db + .create_object_store("people") + .auto_increment(true) + .key_path("id") + .build() + .unwrap(); + let _ = store.add(&NewPerson::new("Joe", "Bloggs"), None, true); + }, + true, + ) + .await + .unwrap(); + let trans = db.transaction_readonly(&["people"]).unwrap(); + let people_store = trans.object_store("people").unwrap(); + let user = people_store.get(1.).await.unwrap().unwrap(); + let people = people_store.get_all().await.unwrap(); + AppState { + db, + user, + people, + new_person: Mutable::new(NewPerson::default()), + } + } + + fn render(&self) -> Dom { + html!("div", { + .child(render_people(&self.people)) + .child(NewPerson::render_form(self.new_person.clone())) + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Person { + id: u64, + first_name: SmartString, + last_name: SmartString, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct NewPerson { + first_name: SmartString, + last_name: SmartString, +} + +impl NewPerson { + fn new(first_name: &str, last_name: &str) -> Self { + Self { + first_name: first_name.into(), + last_name: last_name.into(), + } + } + + fn render_form(this: Mutable) -> Dom { + html!("label", { + .children(&mut [ + text("first name ("), + text_signal(this.signal_cloned().map(|p| p.first_name)), + text(") "), + html!("input" => web_sys::HtmlInputElement, { + .with_node!(input => { + .attr("type", "text") + .attr_signal("value", this.signal_cloned().map(|person| person.first_name)) + .event({ + let this = this.clone(); + move |_evt: events::Input| { + this.lock_mut().first_name = input.value().into(); + } + }) + }) + }), + html!("input" => web_sys::HtmlInputElement, { + .with_node!(input => { + .attr("type", "text") + .attr_signal("value", this.signal_cloned().map(|person| person.last_name)) + .event({ + let this = this.clone(); + move |_evt: events::Input| { + this.lock_mut().last_name = input.value().into(); + } + }) + }) + }) + ]) + }) + } +} + +fn render_people(people: &MutableVec) -> Dom { + html!("div", { + .children_signal_vec(people.signal_vec_cloned().map(|person| { + })) + }) +} + +/// Render the date with the `:` flashing on and off every second into `el`. +async fn use_db(el: web_sys::Element) { + let app = App::new(); + dominator::append_dom(&dominator::get_id("app"), App::render_mutable(app)); +} From 79d6a5fd278e81967c7cbab129c08df510d294d2 Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Wed, 20 Apr 2022 14:29:31 +0100 Subject: [PATCH 4/8] WIP indexeddb wrapper. --- crates/storage/src/indexed_db/cursor.rs | 176 ++++- crates/storage/src/indexed_db/errors.rs | 106 +-- crates/storage/src/indexed_db/index.rs | 119 +++- crates/storage/src/indexed_db/key.rs | 115 +++- crates/storage/src/indexed_db/mod.rs | 106 +-- crates/storage/src/indexed_db/object_store.rs | 611 +++++++++++------- crates/storage/src/indexed_db/transaction.rs | 129 +--- crates/storage/src/indexed_db/util.rs | 12 +- crates/storage/src/lib.rs | 38 ++ examples/storage/README.md | 7 - examples/storage/index.html | 15 - examples/storage/src/lib.rs | 175 ----- examples/{storage => todomvc}/.gitignore | 0 examples/{storage => todomvc}/Cargo.toml | 8 +- examples/todomvc/README.md | 9 + examples/todomvc/index.css | 376 +++++++++++ examples/todomvc/index.html | 20 + examples/todomvc/src/app.rs | 358 ++++++++++ examples/todomvc/src/lib.rs | 38 ++ examples/todomvc/src/macros.rs | 11 + examples/todomvc/src/todo.rs | 164 +++++ examples/todomvc/src/util.rs | 10 + 22 files changed, 1865 insertions(+), 738 deletions(-) delete mode 100644 examples/storage/README.md delete mode 100644 examples/storage/index.html delete mode 100644 examples/storage/src/lib.rs rename examples/{storage => todomvc}/.gitignore (100%) rename examples/{storage => todomvc}/Cargo.toml (74%) create mode 100644 examples/todomvc/README.md create mode 100644 examples/todomvc/index.css create mode 100644 examples/todomvc/index.html create mode 100644 examples/todomvc/src/app.rs create mode 100644 examples/todomvc/src/lib.rs create mode 100644 examples/todomvc/src/macros.rs create mode 100644 examples/todomvc/src/todo.rs create mode 100644 examples/todomvc/src/util.rs diff --git a/crates/storage/src/indexed_db/cursor.rs b/crates/storage/src/indexed_db/cursor.rs index 5bb9acf4..881aab80 100644 --- a/crates/storage/src/indexed_db/cursor.rs +++ b/crates/storage/src/indexed_db/cursor.rs @@ -1,43 +1,92 @@ -use super::{errors, StreamingRequest}; +use super::{errors, util::UnreachableExt, Request, StreamingRequest}; use futures::stream::Stream; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{ cell::Cell, - num::NonZeroU32, + marker::PhantomData, ops::Deref, pin::Pin, + sync::{ + atomic::{AtomicU8, Ordering}, + Arc, + }, task::{Context, Poll}, }; -use wasm_bindgen::{prelude::*, throw_str, JsCast, UnwrapThrowExt}; +use wasm_bindgen::{prelude::*, throw_str, JsCast}; use web_sys::{IdbCursor, IdbCursorDirection, IdbCursorWithValue}; +#[derive(Debug, Clone)] +struct StreamState { + /// - `0`: No cursor + /// - `1`: Active cursor + /// - `2`: Multi cursors error + inner: Arc, +} + +impl StreamState { + fn new() -> Self { + Self { + inner: Arc::new(AtomicU8::new(0)), + } + } + + fn take(&self) -> bool { + if self.inner.load(Ordering::SeqCst) == 0 { + self.inner + .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + } else { + // Set to error unconditionally. + self.inner.store(2, Ordering::SeqCst); + false + } + } + + fn untake(&self) { + let _ = self + .inner + .compare_exchange(1, 0, Ordering::SeqCst, Ordering::SeqCst); + } +} + /// Represents an async stream of values from the DB. use the `Stream` impl to access the cursor /// and its values. #[derive(Debug)] -pub struct CursorStream { +pub struct CursorStream { /// Every time the request succeeds, its result is an instance of cursor. request: StreamingRequest, + ty: PhantomData, + state: StreamState, } -impl CursorStream { +impl CursorStream { pub(crate) fn new(request: StreamingRequest) -> Self { - Self { request } + Self { + request, + ty: PhantomData, + state: StreamState::new(), + } } } -impl Stream for CursorStream { - type Item = Result; +impl Stream for CursorStream { + type Item = Result, errors::LifetimeError>; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match Pin::new(&mut self.request).poll_next(cx) { Poll::Pending => Poll::Pending, Poll::Ready(None) => Poll::Ready(None), - Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(errors::CursorError::from(e)))), + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(errors::LifetimeError::from(e)))), Poll::Ready(Some(Ok(next))) => { - let cursor = next - .dyn_into::() - .expect_throw("unreachable"); - Poll::Ready(Some(Ok(Cursor::new(cursor)))) + let cursor = next.dyn_into::().unwrap_unreachable(); + if self.state.take() { + Poll::Ready(Some(Ok(Cursor::new(cursor, self.state.clone())))) + } else { + // TODO add an error type here for overlapping cursors. + Poll::Ready(Some(Err(errors::LifetimeError::Unexpected( + "overlapping cursors".into(), + )))) + } } } } @@ -49,14 +98,14 @@ impl Stream for CursorStream { /// modelled by having `Cursor` (cursors with values) `Deref` to `KeyCursor` (cursors without /// values). #[derive(Debug)] -pub struct Cursor { - inner: KeyCursor, +pub struct Cursor { + inner: KeyCursor, } -impl Cursor { - fn new(inner: IdbCursorWithValue) -> Self { +impl Cursor { + fn new(inner: IdbCursorWithValue, state: StreamState) -> Self { Self { - inner: KeyCursor::new(inner.into()), + inner: KeyCursor::new(inner.into(), state), } } @@ -66,7 +115,7 @@ impl Cursor { /// Get the value at the current location of this cursor. pub fn value_raw(&self) -> JsValue { - self.raw().value().expect_throw("unreachable") + self.raw().value().unwrap_unreachable() } /// The value of the object the cursor is currently pointing to. @@ -76,10 +125,51 @@ impl Cursor { { serde_wasm_bindgen::from_value(self.value_raw()) } + + /// Update the value the cursor is currently pointing to. + /// + /// Note that the primary key must remain the same. If the primary key is changed (only + /// possible using in-tree primary keys) then an error will be returned. + pub async fn update_raw( + &self, + updated_value: &JsValue, + bubble_errors: bool, + ) -> Result<(), errors::LifetimeError> { + let req_raw = self.raw().update(updated_value)?; + Request::new(req_raw, bubble_errors).await?; + Ok(()) + } + + // TODO we need to handle the error case where the updated value changed the primary key (which + // will cause an exception). Need a new error type. + /// Update the value the cursor is currently pointing to. + /// + /// Note that the primary key must remain the same. If the primary key is changed (only + /// possible using in-tree primary keys) then an error will be returned. + pub async fn update( + &self, + updated_value: &V, + bubble_errors: bool, + ) -> Result<(), errors::DeSerialize> + where + V: Serialize, + { + let raw_value = serde_wasm_bindgen::to_value(updated_value)?; + self.update_raw(&raw_value, bubble_errors) + .await + .map_err(errors::DeSerialize::Other) + } + + /// Delete the value the cursor is currently pointing to. + pub async fn delete(&self, bubble_errors: bool) -> Result<(), errors::LifetimeError> { + let req_raw = self.raw().delete()?; + Request::new(req_raw, bubble_errors).await?; + Ok(()) + } } -impl Deref for Cursor { - type Target = KeyCursor; +impl Deref for Cursor { + type Target = KeyCursor; fn deref(&self) -> &Self::Target { &self.inner @@ -88,18 +178,23 @@ impl Deref for Cursor { /// Wrapper round IDBCursor #[derive(Debug)] -pub struct KeyCursor { +pub struct KeyCursor { inner: IdbCursor, /// Keep track of if the user has advanced the cursor somehow (if they don't we call `advance` /// on drop) advanced: Cell, + /// Ensure that only one cursor object is held at any one time. + state: StreamState, + ty: PhantomData, } -impl KeyCursor { - fn new(inner: IdbCursor) -> Self { +impl KeyCursor { + fn new(inner: IdbCursor, state: StreamState) -> Self { Self { inner, advanced: Cell::new(false), + state, + ty: PhantomData, } } @@ -111,7 +206,7 @@ impl KeyCursor { /// Get the primary key for the current record. pub fn primary_key_raw(&self) -> JsValue { // Unwrap: the `Stream` implementation ensures that the cursor is valid and not moving - self.inner.primary_key().expect_throw("unreachable") + self.inner.primary_key().unwrap_unreachable() } /// Get the primary key for the current record. @@ -123,10 +218,15 @@ impl KeyCursor { } /// Advance the cursor by the given value. - pub fn advance(self, amount: NonZeroU32) -> Result<(), errors::AdvanceError> { - self.inner - .advance(amount.get()) - .map_err(errors::AdvanceError::from)?; + /// + /// # Panics + /// + /// This function will panic if `amount` is `0`. + pub fn advance(self, amount: u32) -> Result<(), errors::LifetimeError> { + if amount == 0 { + throw_str("advance amount must be > 0"); + } + self.inner.advance(amount)?; self.advanced.set(true); Ok(()) } @@ -134,13 +234,23 @@ impl KeyCursor { /// Move the cursor on to the next record. /// /// Equivalent to `cursor.advance(1)` - pub fn continue_(self) -> Result<(), errors::AdvanceError> { - self.inner.continue_().map_err(errors::AdvanceError::from)?; + pub fn continue_(self) -> Result<(), errors::LifetimeError> { + self.inner.continue_()?; self.advanced.set(true); Ok(()) } } +impl Drop for KeyCursor { + fn drop(&mut self) { + if !self.advanced.get() { + // ignore errors + let _ = self.inner.continue_(); + } + self.state.untake(); + } +} + /// Possible modes a cursor can be in (fowards or backwards, and unique variants). /// /// Note that below the term `source` means 'the thing that this cursor points to', which could be @@ -193,7 +303,7 @@ impl From for CursorDirection { IdbCursorDirection::Nextunique => CursorDirection::NextUnique, IdbCursorDirection::Prev => CursorDirection::Prev, IdbCursorDirection::Prevunique => CursorDirection::PrevUnique, - _ => throw_str("unexpected indexeddb cursor direction"), + _ => throw_str("unreachable"), } } } diff --git a/crates/storage/src/indexed_db/errors.rs b/crates/storage/src/indexed_db/errors.rs index 7ccacc76..5ba26557 100644 --- a/crates/storage/src/indexed_db/errors.rs +++ b/crates/storage/src/indexed_db/errors.rs @@ -281,102 +281,60 @@ error_from_jsvalue!(DeleteError { "DataError" => InvalidKey, }); -/// An error when deleting objects from an objecct store +/// An error when opening an index #[derive(Debug, Error)] -pub enum GetError { - /// Tried to get an object within a transaction that has finished - #[error("tried to get an object within a transaction that has finished")] - TransactionInactive, - /// The object store was deleted or moved - #[error("the object store was deleted or moved")] - StoreNotFound, - /// The given key was not a valid key - /// - /// This should only happen in edge cases. - #[error("the given key was not a valid key")] - InvalidKey, - /// Could not deserialize results - #[error("could not deserialize results")] - Deserialize(serde_wasm_bindgen::Error), +pub enum IndexError { + /// The source object store has been deleted or the current transaction has finished. + #[error("the source object store has been deleted or the current transaction has finished")] + InvalidState, + /// No index with the given name exists. + #[error("no index with the given name exists")] + NotFound, /// Unexpected error #[error("unexpected error: {0}")] Unexpected(String), } -error_from_jsvalue!(GetError { - "TransactionInactiveError" => TransactionInactive, - "InvalidStateError" => StoreNotFound, - "DataError" => InvalidKey, +error_from_jsvalue!(IndexError { + "InvalidStateError" => InvalidState, }); -/// An error when deleting objects from an objecct store +/// Errors that occur when some object we rely on is no longer active. +/// +/// In reality, the two variants below are not used consistently throughout, so it might be better +/// to combine them. #[derive(Debug, Error)] -pub enum CursorError { - /// Tried to open an cursor within a transaction that has finished - #[error("tried to open a cursor within a transaction that has finished")] +pub enum LifetimeError { + /// The transaction is no longer active + /// + /// This can happen if we try to use a transaction after the user agent (browser) has + /// auto-comitted it. + #[error("the transaction is no longer active")] TransactionInactive, - /// The object store was deleted or moved - #[error("the object store was deleted or moved")] - StoreNotFound, - /// The given key was not a valid key + /// This error occurs when the object we are running a query against has been deleted. /// - /// This should only happen in edge cases, and only when a query is used. - #[error("the given key was not a valid key")] - InvalidKey, - /// Could not deserialize results - #[error("could not deserialize results")] - Deserialize(serde_wasm_bindgen::Error), + /// It is unlikely to be seen outside of an upgrade transaction. + #[error("the current operation is not possible because of indexedDB's state")] + InvalidState, /// Unexpected error #[error("unexpected error: {0}")] Unexpected(String), } -error_from_jsvalue!(CursorError { +error_from_jsvalue!(LifetimeError { "TransactionInactiveError" => TransactionInactive, - "InvalidStateError" => StoreNotFound, - "DataError" => InvalidKey, -}); - -/// An error when deleting objects from an objecct store -#[derive(Debug, Error)] -pub enum KeyRangeError { - /// Either upper < lower, upper = lower and at least one is closed, or upper or lower not a - /// valid key - // TODO options here are to panic on invalid ranges or check the conditions ourselves. - #[error("upper < lower, upper = lower and one is closed, or upper or lower not a valid key")] - InvalidParams, - /// Unexpected error - #[error("unexpected error: {0}")] - Unexpected(String), -} - -error_from_jsvalue!(KeyRangeError { - "DataError" => InvalidParams, + "InvalidStateError" => InvalidState, }); -/// An error when deleting objects from an objecct store +/// A wrapper around other errors to include a de/serialization error variant. #[derive(Debug, Error)] -pub enum AdvanceError { - /// The amount was not a positive integer (should be unreachable) - #[error("The amount was not a positive integer (should be unreachable)")] - InvalidParams, - /// The transaction this cursor is attached to is no longer active - #[error("the transaction this cursor is attached to is no longer active")] - TransactionInactive, - /// The cursor is past the end of the object store (should be unreachable) - #[error("the cursor is past the end of the object store (should be unreachable)")] - PastEnd, - /// Unexpected error - #[error("unexpected error: {0}")] - Unexpected(String), +pub enum DeSerialize { + /// A ser/de error + DeSerialize(#[from] serde_wasm_bindgen::Error), + /// A non-ser/de error + Other(E), } -error_from_jsvalue!(AdvanceError { - "TypeError" => InvalidParams, - "TransactionInactiveError" => TransactionInactive, - "InvalidStateError" => PastEnd, -}); - // key conversions /// Tried to use a f64 NaN as a key diff --git a/crates/storage/src/indexed_db/index.rs b/crates/storage/src/indexed_db/index.rs index d9996524..0545d054 100644 --- a/crates/storage/src/indexed_db/index.rs +++ b/crates/storage/src/indexed_db/index.rs @@ -1,13 +1,126 @@ +use super::{errors, util::UnreachableExt, Query, Request, Upgrade}; +use serde::Deserialize; +use std::marker::PhantomData; +use wasm_bindgen::prelude::*; use web_sys::IdbIndex; /// An object store index #[derive(Debug)] -pub struct Index { +pub struct Index { inner: IdbIndex, + ty: PhantomData, } -impl Index { +impl Index { pub(crate) fn new(inner: IdbIndex) -> Self { - Self { inner } + Self { + inner, + ty: PhantomData, + } + } + + /// Get the path to the key/keys for this index. + pub fn key_path(&self) -> JsValue { + self.inner.key_path().unwrap_unreachable() + } + + /// How an array key is handled. + /// + /// If true, there is one record in the index for each item in the array. If false, then there + /// is a single entry whose key is an array key. + pub fn is_multi_entry(&self) -> bool { + self.inner.multi_entry() + } + + /// Whether keys in the index must be unique. + pub fn is_unique(&self) -> bool { + self.inner.unique() + } + + /// Count the number of records in this index. + pub async fn count(&self) -> Result { + let request = self.inner.count().map_err(errors::LifetimeError::from)?; + let count = Request::new(request, false) + .await + .map_err(errors::LifetimeError::from)?; + let count = count.as_f64().unwrap_unreachable(); + // assume count is a valid u32 + Ok(count as u32) + } + + /// Get the first object matching the given query in the index. + // Strictly speaking these take a key range or undefined, but only return the first record in + // those cases. For now I'm not implementing for those variants. + pub async fn get_raw(&self, query: &Query) -> Result { + let request = self + .inner + .get(query.as_ref()) + .map_err(errors::LifetimeError::from)?; + Request::new(request, false) + .await + .map_err(errors::LifetimeError::from) + } + + /// Get the first object matching the given query in the index and deserialize it. + pub async fn get( + &self, + query: &Query, + ) -> Result> + where + V: for<'de> Deserialize<'de>, + { + let res = self + .get_raw(query) + .await + .map_err(errors::DeSerialize::Other)?; + Ok(serde_wasm_bindgen::from_value(res)?) + } + + /// Get the objects matching the given query in the index. + pub async fn get_all_raw( + &self, + query: &Query, + count: Option, + ) -> Result { + let request = if let Some(count) = count { + self.inner.get_all_with_key_and_limit(query.as_ref(), count) + } else { + self.inner.get_all_with_key(query.as_ref()) + } + .map_err(errors::LifetimeError::from)?; + Request::new(request, false) + .await + .map_err(errors::LifetimeError::from) + } + + /// Get the objects matching the given query in the index. + /// + /// `V` should be a collection type. + pub async fn get_all( + &self, + query: &Query, + count: Option, + ) -> Result> + where + V: for<'de> Deserialize<'de>, + { + let res = self + .get_all_raw(query, count) + .await + .map_err(errors::DeSerialize::Other)?; + Ok(serde_wasm_bindgen::from_value(res)?) + } +} + +impl Index { + /// Change the name of this index. + /// + /// # Panics + /// + /// Currently this function panics rather than returning an error if the underlying js fn + /// throws. This is a limitation of `web_sys`. + pub fn set_name(&self, name: &str) -> Result<(), errors::CreateIndexError> { + self.inner.set_name(name); + Ok(()) } } diff --git a/crates/storage/src/indexed_db/key.rs b/crates/storage/src/indexed_db/key.rs index 3c3abd55..21770717 100644 --- a/crates/storage/src/indexed_db/key.rs +++ b/crates/storage/src/indexed_db/key.rs @@ -2,7 +2,8 @@ use super::{errors, indexed_db}; use std::{ cmp::Ordering, convert::TryFrom, - ops::{Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive}, + iter::FromIterator, + ops::{Deref, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive}, }; use wasm_bindgen::{prelude::*, throw_str}; use web_sys::IdbKeyRange; @@ -131,6 +132,14 @@ impl Ord for Key { } } +impl Deref for Key { + type Target = JsValue; + + fn deref(&self) -> &JsValue { + &self.0 + } +} + impl TryFrom for Key { type Error = errors::NumberIsNan; @@ -181,72 +190,112 @@ impl From<&[Key]> for Key { } } +impl FromIterator for Key { + fn from_iter>(iter: T) -> Self { + let array = js_sys::Array::new(); + for el in iter { + array.push(&el.0); + } + Key(array.into()) + } +} + /// A query to filter a sequence of records (to those that match the query). /// /// It is either no restriction (`Query::ALL`), a specific value of the `Key`, or a range of /// `Key` values. #[derive(Debug)] pub struct Query { - /// `None` means `all records` - pub(crate) inner: Option, + inner: JsValue, } +// TODO error type impl Query { /// A special range that includes all records in a store/index. - pub const ALL: Self = Self { inner: None }; + pub const ALL: Self = Self { + inner: JsValue::UNDEFINED, + }; - /// Create a new `Query`. - fn new(inner: Result) -> Self { + /// Create a range that will only match the given key. + pub fn only(key: &Key) -> Self { + Self::new(IdbKeyRange::only(&key).expect_throw("unreachable")) + } + + /// Create a query from a given range. + /// + /// Each parameter, if specified, is a tuple of (`value`, `open`) where `value` is the value of + /// this end of the range, and `open` is whether the value given should be included, or only + /// those more/less than it (same meaning as open/closed intervals in math). If unspecified, + /// the range is unbounded in that direction. + /// + /// The error semantics are there to match the JavaScript equivalent function. They aren't + /// quite ideomatic Rust (I think the ideomatic thing to do is to return an empty collection if + /// upper < lower), but it's more important to be familiar to JS users, I think. + #[inline] + pub fn from_range( + lower: Option<(&Key, bool)>, + upper: Option<(&Key, bool)>, + ) -> Result { + match (lower, upper) { + (None, None) => return Ok(Self::ALL), + (None, Some((upper, upper_open))) => { + IdbKeyRange::upper_bound_with_open(&upper.0, upper_open) + } + (Some((lower, lower_open)), None) => { + IdbKeyRange::lower_bound_with_open(&lower.0, lower_open) + } + (Some((lower, lower_open)), Some((upper, upper_open))) => { + IdbKeyRange::bound_with_lower_open_and_upper_open( + &lower.0, &upper.0, lower_open, upper_open, + ) + } + } + .map(Self::new) + .map_err(|_| ()) + } + + fn new(inner: IdbKeyRange) -> Self { Self { - inner: Some(inner.expect_throw("keyrange error not caught (should be unreachable)")), + inner: inner.into(), } } } -impl From> for Query { - fn from(range: Range) -> Self { - if range.start >= range.end { - throw_str("lower bound was >= upper bound (the range is empty)"); - } - Self::new(IdbKeyRange::bound_with_lower_open_and_upper_open( - &range.start.0, - &range.end.0, - false, - true, - )) +impl TryFrom> for Query { + type Error = (); + fn try_from(range: Range) -> Result { + Self::from_range(Some((&range.start, false)), Some((&range.end, true))) } } -impl From> for Query { - fn from(range: RangeInclusive) -> Self { - if range.start() > range.end() { - throw_str("lower bound was > upper bound (the range is empty)"); - } - Self::new(IdbKeyRange::bound(&range.start().0, &range.end().0)) +impl TryFrom> for Query { + type Error = (); + fn try_from(range: RangeInclusive) -> Result { + Self::from_range(Some((range.start(), false)), Some((range.end(), false))) } } impl From> for Query { fn from(range: RangeFrom) -> Self { - Self::new(IdbKeyRange::lower_bound(&range.start.0)) + Self::from_range(Some((&range.start, false)), None).expect_throw("unreachable") } } impl From> for Query { fn from(range: RangeTo) -> Self { - Self::new(IdbKeyRange::upper_bound_with_open(&range.end.0, true)) + Self::from_range(None, Some((&range.end, true))).expect_throw("unreachable") } } impl From> for Query { fn from(range: RangeToInclusive) -> Self { - Self::new(IdbKeyRange::upper_bound(&range.end.0)) + Self::from_range(None, Some((&range.end, false))).expect_throw("unreachable") } } -impl From for Query { - fn from(key: Key) -> Self { - Self::new(IdbKeyRange::only(&key.0)) +impl From<&Key> for Query { + fn from(key: &Key) -> Self { + Self::only(key) } } @@ -255,3 +304,9 @@ impl From for Query { Self::ALL } } + +impl AsRef for Query { + fn as_ref(&self) -> &JsValue { + &self.inner + } +} diff --git a/crates/storage/src/indexed_db/mod.rs b/crates/storage/src/indexed_db/mod.rs index bb73259f..cd1f2c8c 100644 --- a/crates/storage/src/indexed_db/mod.rs +++ b/crates/storage/src/indexed_db/mod.rs @@ -15,19 +15,17 @@ use std::{ use wasm_bindgen::{prelude::*, throw_str, JsCast, UnwrapThrowExt}; use web_sys::{ DomException, IdbDatabase, IdbFactory, IdbObjectStoreParameters, IdbOpenDbRequest, IdbRequest, - IdbRequestReadyState, IdbTransaction, IdbTransactionMode, IdbVersionChangeEvent, + IdbRequestReadyState, IdbTransactionMode, IdbVersionChangeEvent, }; mod util; pub use util::{StringList, StringListIter}; mod object_store; -pub use object_store::{ - CreateIndex, ObjectStoreDuringUpgrade, ObjectStoreReadOnly, ObjectStoreReadWrite, OpenCursor, -}; +pub use object_store::{CursorOptions, IndexOptions, ObjectStore}; mod key; pub use key::{IntoKeyPath, Key, KeyPath, Query}; mod transaction; -pub use transaction::{TransactionDuringUpgrade, TransactionReadOnly, TransactionReadWrite}; +pub use transaction::Transaction; mod index; pub use index::Index; mod cursor; @@ -130,7 +128,7 @@ impl Database { // This seems to be the way to get a transation let transaction = request.transaction().expect_throw("request.transaction()"); - let transaction = TransactionDuringUpgrade::new(transaction); + let transaction: Transaction = Transaction::new(transaction); upgrade_fn(DatabaseDuringUpgrade { old_version, @@ -176,36 +174,31 @@ impl Database { pub fn transaction_readwrite( &self, stores: &[impl AsRef], - ) -> Result { - let array = js_sys::Array::new(); - for store in stores { - array.push(&JsValue::from_str(store.as_ref())); - } - self.transaction_inner(&array, IdbTransactionMode::Readwrite) - .map(TransactionReadWrite::new) + ) -> Result, errors::StartTransactionError> { + self.transaction_inner(stores, IdbTransactionMode::Readwrite) } /// Open a transaction with access to the given stores in "readonly" mode. pub fn transaction_readonly( &self, stores: &[impl AsRef], - ) -> Result { - let array = js_sys::Array::new(); - for store in stores { - array.push(&JsValue::from_str(store.as_ref())); - } - self.transaction_inner(&array, IdbTransactionMode::Readonly) - .map(TransactionReadOnly::new) + ) -> Result, errors::StartTransactionError> { + self.transaction_inner(stores, IdbTransactionMode::Readonly) } - fn transaction_inner( + fn transaction_inner( &self, - stores: &JsValue, + stores: &[impl AsRef], mode: IdbTransactionMode, - ) -> Result { + ) -> Result, errors::StartTransactionError> { + let array = js_sys::Array::new(); + for store in stores { + array.push(&JsValue::from_str(store.as_ref())); + } self.inner - .transaction_with_str_sequence_and_mode(stores, mode) + .transaction_with_str_sequence_and_mode(&array, mode) .map_err(errors::StartTransactionError::from) + .map(Transaction::new) } } @@ -225,7 +218,7 @@ pub struct DatabaseDuringUpgrade<'trans> { old_version: u32, new_version: u32, db: Database, - transaction: &'trans TransactionDuringUpgrade, + transaction: &'trans Transaction, } impl<'trans> DatabaseDuringUpgrade<'trans> { @@ -242,6 +235,11 @@ impl<'trans> DatabaseDuringUpgrade<'trans> { /// Create a new object store in the database /// + /// # Panics + /// + /// This function will panic if `auto_increment` is set to `false` (the default), and + /// `key_path` is empty or not set. + /// /// # Example /// /// ```no_run @@ -250,12 +248,16 @@ impl<'trans> DatabaseDuringUpgrade<'trans> { /// .key_path("key.path") /// .build() /// ``` - pub fn create_object_store<'a>(&'a self, name: &'a str) -> CreateObjectStore<'a> { - CreateObjectStore { - name, - params: IdbObjectStoreParameters::new(), - db: &self.db.inner, - } + pub fn create_object_store<'a>( + &'a self, + name: &'a str, + opts: ObjectStoreOptions, + ) -> Result, errors::CreateObjectStoreError> { + self.db + .inner + .create_object_store_with_optional_parameters(name, &opts.inner) + .map(ObjectStore::new) + .map_err(errors::CreateObjectStoreError::from) } /// Delete an object store from the database /// @@ -268,11 +270,11 @@ impl<'trans> DatabaseDuringUpgrade<'trans> { .map_err(errors::DeleteObjectStoreError::from) } - /// Get the database upgrade transaction. + /// Get the transaction this database upgrade is running in. /// /// Use this method rather than `Database::start_*_transaction` if you want to rename or add indexes to /// already existing object stores. - pub fn upgrade_transaction(&self) -> &'trans TransactionDuringUpgrade { + pub fn transaction(&self) -> &'trans Transaction { self.transaction } } @@ -285,20 +287,25 @@ impl<'trans> Deref for DatabaseDuringUpgrade<'trans> { } } -/// Builder struct to create object stores +/// Possible objects when creating an object store #[derive(Debug)] -pub struct CreateObjectStore<'a> { - name: &'a str, - params: IdbObjectStoreParameters, - db: &'a IdbDatabase, +pub struct ObjectStoreOptions { + inner: IdbObjectStoreParameters, } -impl<'a> CreateObjectStore<'a> { +impl ObjectStoreOptions { + /// The default options + pub fn new() -> Self { + Self { + inner: Default::default(), + } + } + /// If `true`, the object store has a /// [key generator](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#key_generator). /// Defaults to `false`. pub fn auto_increment(mut self, auto_increment: bool) -> Self { - self.params.auto_increment(auto_increment); + self.inner.auto_increment(auto_increment); self } @@ -309,25 +316,20 @@ impl<'a> CreateObjectStore<'a> { /// [key path]: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#key_path /// [out-of-line keys]: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Basic_Terminology#out-of-line_key pub fn key_path(mut self, key_path: impl IntoKeyPath) -> Self { - self.params.key_path(Some(&key_path.into_jsvalue())); + self.inner.key_path(Some(&key_path.into_jsvalue())); self } +} - /// Actually create the object store using the configured builder. - /// - /// # Panics - /// - /// This function will panic if `auto_increment` is set to `false` (the default), and - /// `key_path` is empty or not set. - pub fn build(self) -> Result { - self.db - .create_object_store_with_optional_parameters(self.name, &self.params) - .map(ObjectStoreDuringUpgrade::new) - .map_err(errors::CreateObjectStoreError::from) +impl Default for ObjectStoreOptions { + fn default() -> Self { + Self::new() } } /// Wrapper around IdbRequest that implements `Future`. +/// +/// We don't need to expose this - we just return it as an `impl Future<_>`. struct Request { inner: IdbRequest, bubble_errors: bool, diff --git a/crates/storage/src/indexed_db/object_store.rs b/crates/storage/src/indexed_db/object_store.rs index 84fa9d27..6e1ee3b5 100644 --- a/crates/storage/src/indexed_db/object_store.rs +++ b/crates/storage/src/indexed_db/object_store.rs @@ -1,33 +1,42 @@ // NOTE: all transaction operations must be started on the *same tick* (i.e. not in an async block) // otherwise with transaction will auto-commit before the operation is started. use super::{ - errors, CursorDirection, CursorStream, Index, IntoKeyPath, Key, KeyPath, Query, Request, - StreamingRequest, StringList, TransactionDuringUpgrade, + errors, util::UnreachableExt, CursorDirection, CursorStream, Index, IntoKeyPath, Key, KeyPath, + Query, ReadOnly, ReadWrite, Request, StreamingRequest, StringList, Upgrade, }; -use futures::{ - future::{ready, Either}, - FutureExt, -}; -use serde::{de::DeserializeOwned, Serialize}; -use std::{convert::TryFrom, future::Future, ops::Deref}; -use wasm_bindgen::{prelude::*, throw_str, JsCast, UnwrapThrowExt}; +use serde::{Deserialize, Serialize}; +use std::{future::Future, marker::PhantomData}; +use wasm_bindgen::{prelude::*, throw_str, JsCast}; use web_sys::{IdbIndexParameters, IdbObjectStore}; -/// An object store during a database upgrade. +/// An indexedDB object store. /// -/// Note that object stores are always sorted with their key values ascending. +/// The type `Ty` denotes what context the store exists (`Upgrade`, `ReadWrite`, or `ReadOnly`). #[derive(Debug)] -pub struct ObjectStoreDuringUpgrade { - inner: ObjectStoreReadWrite, +pub struct ObjectStore { + inner: IdbObjectStore, + ty: PhantomData, } -impl ObjectStoreDuringUpgrade { +impl ObjectStore { + /// Contract: the caller is responsible for choosing the right subtype pub(crate) fn new(inner: IdbObjectStore) -> Self { Self { - inner: ObjectStoreReadWrite::new(inner), + inner, + ty: PhantomData, } } + fn raw(&self) -> &IdbObjectStore { + &self.inner + } + + // We actually implement all methods privately for all variants and then publicly expose only + // those methods that are valid. Hopefully this should help reduce code duplication (TODO does + // it actually?) + + // Only valid during upgrade + /// Changes the object store's name. /// /// # Panics @@ -35,141 +44,106 @@ impl ObjectStoreDuringUpgrade { /// Currently this method will panic on error. If/when [this wasm-bindgen patch](https://github.com/rustwasm/wasm-bindgen/pull/2852) /// lands errors will be returned instead. Because of the return type, the change will be /// backwards-compatible. - pub fn set_name(&self, new_name: &str) -> Result<(), errors::SetNameError> { + fn set_name_inner(&self, new_name: &str) -> Result<(), errors::SetNameError> { self.raw().set_name(new_name); Ok(()) } /// Create a new index for the object store. - pub fn create_index<'b, K: IntoKeyPath>( - &'b self, - name: &'b str, - key_path: K, - ) -> CreateIndex<'b, K> { - CreateIndex { - store: self.raw(), - name, - key_path, - params: IdbIndexParameters::new(), - } + fn create_index_inner( + &self, + name: &str, + opts: IndexOptions, + ) -> Result, errors::CreateIndexError> { + self.inner + .create_index_with_str_sequence_and_optional_parameters( + name, + &opts.key_path, + &opts.params, + ) + .map(Index::new) + .map_err(errors::CreateIndexError::from) } /// Delete the index with the given name. - pub fn delete_index(&self, name: &str) -> Result<(), errors::DeleteIndexError> { + fn delete_index_inner(&self, name: &str) -> Result<(), errors::DeleteIndexError> { self.raw() .delete_index(name) .map_err(errors::DeleteIndexError::from) } -} - -impl Deref for ObjectStoreDuringUpgrade { - type Target = ObjectStoreReadWrite; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -/// A builder object for creating an index. -/// -/// Once you have set the options you require, call `build` to run the operation. -#[derive(Debug)] -pub struct CreateIndex<'a, K: IntoKeyPath> { - store: &'a IdbObjectStore, - name: &'a str, - key_path: K, - params: IdbIndexParameters, -} - -impl<'a, K: IntoKeyPath> CreateIndex<'a, K> { - /// If `true`, the index will not allow duplicate values for a single key. - pub fn unique(mut self, yes: bool) -> Self { - self.params.unique(yes); - self - } - - /// If `true`, the index will add an entry in the index for each array element when the - /// *keyPath* resolves to an `Array`. If `false`, it will add one single entry containing the - /// `Array`. - pub fn multi_entry(mut self, yes: bool) -> Self { - self.params.multi_entry(yes); - self - } - - // TODO potentially add `locale` in the future. - - /// - /// Note that object stores are always sorted with their key values ascending. - /// Run the create index operation. - /// - /// If you don't call this method, then the index won't be created. - pub fn build(self) -> Result { - self.store - .create_index_with_str_sequence_and_optional_parameters( - self.name, - &self.key_path.into_jsvalue(), - &self.params, - ) - .map(Index::new) - .map_err(errors::CreateIndexError::from) - } -} -/// An object store during a `readwrite` transaction. -/// -/// Note that object stores are always sorted with their key values ascending. -#[derive(Debug)] -pub struct ObjectStoreReadWrite { - inner: ObjectStoreReadOnly, -} - -impl ObjectStoreReadWrite { - pub(crate) fn new(inner: IdbObjectStore) -> Self { - Self { - inner: ObjectStoreReadOnly::new(inner), - } - } + // Valid during upgrade or read/write transaction /// Add an object to the database. /// /// This method returns a future, but always tries to add the object irrespective of whether /// the future is ever polled. If `bubble_errors = true` any errors returned here will also /// cause the transaction to abort. - pub fn add_raw( + async fn add_raw_inner( &self, value: &JsValue, key: Option, bubble_errors: bool, - ) -> impl Future> { + ) -> Result<(), errors::AddError> { let request = if let Some(key) = key { - self.raw().add_with_key(value, &key.0) + self.raw().add_with_key(value, &key) } else { self.raw().add(value) }; - async move { - let request = match request { - Ok(request) => request, - Err(e) => return Err(errors::AddError::from(e)), - }; - match Request::new(request, bubble_errors).await { - Ok(_) => Ok(()), - Err(e) => Err(errors::AddError::from(e)), - } + let request = match request { + Ok(request) => request, + Err(e) => return Err(errors::AddError::from(e)), + }; + match Request::new(request, bubble_errors).await { + Ok(_) => Ok(()), + Err(e) => Err(errors::AddError::from(e)), } } /// Add an arbitrary object to the database using serde to serialize it to a JsValue. - pub fn add( + async fn add_inner( + &self, + value: &(impl Serialize + ?Sized), + key: Option, + bubble_errors: bool, + ) -> Result<(), errors::AddError> { + // TODO handle errors + let value = serde_wasm_bindgen::to_value(value).unwrap_unreachable(); + self.add_raw_inner(&value, key, bubble_errors).await + } + + async fn put_raw_inner( + &self, + value: &JsValue, + key: Option, + bubble_errors: bool, + ) -> Result<(), errors::AddError> { + let request = if let Some(key) = key { + self.raw().put_with_key(value, &key) + } else { + self.raw().put(value) + }; + + let request = match request { + Ok(request) => request, + Err(e) => return Err(errors::AddError::from(e)), + }; + match Request::new(request, bubble_errors).await { + Ok(_) => Ok(()), + Err(e) => Err(errors::AddError::from(e)), + } + } + + async fn put_inner( &self, value: &(impl Serialize + ?Sized), key: Option, bubble_errors: bool, - ) -> impl Future> { + ) -> Result<(), errors::AddError> { // TODO handle errors - let value = serde_wasm_bindgen::to_value(value).unwrap(); - let request = self.add_raw(&value, key, bubble_errors); - async move { request.await } + let value = serde_wasm_bindgen::to_value(value).unwrap_unreachable(); + self.put_raw_inner(&value, key, bubble_errors).await } /// Delete all objects in this object store. @@ -179,7 +153,7 @@ impl ObjectStoreReadWrite { /// /// If `bubble_errors = true` then an error here will also cause the transaction to abort, /// whether the error is handled or not. - pub fn clear( + fn clear_inner( &self, bubble_errors: bool, ) -> impl Future> { @@ -195,72 +169,54 @@ impl ObjectStoreReadWrite { /// Delete records from the store that match the given key. // TODO delete_range function - pub async fn delete( + fn delete_inner( &self, - key: impl Into, + key: Key, bubble_errors: bool, - ) -> Result<(), errors::DeleteError> { - // give the optimizer the choice of inlining this function or not (minus generics) - async fn delete_inner( - this: &ObjectStoreReadWrite, - key: Key, - bubble_errors: bool, - ) -> Result<(), errors::DeleteError> { - let request = this - .raw() - .delete(&key.0) - .map_err(errors::DeleteError::from)?; + ) -> impl Future> { + let request = self.inner.delete(&key); + async move { + let request = request.map_err(errors::DeleteError::from)?; Request::new(request, bubble_errors) .await .map(|_| ()) .map_err(errors::DeleteError::from) } - - delete_inner(self, key.into(), bubble_errors).await - } -} -impl Deref for ObjectStoreReadWrite { - type Target = ObjectStoreReadOnly; - - fn deref(&self) -> &Self::Target { - &self.inner } -} -/// An object store during a `readonly` transaction. -/// -/// Note that object stores are always sorted with their key values ascending. To iterate in the -/// descending direction use `CursorDirection::Previous`. -#[derive(Debug)] -pub struct ObjectStoreReadOnly { - inner: IdbObjectStore, - // TODO cache key path so we can provide better errors when key not supplied? -} - -impl ObjectStoreReadOnly { - pub(crate) fn new(inner: IdbObjectStore) -> Self { - Self { inner } + /// Execute the request and return an object implementing `Stream>`. + fn cursor_inner( + &self, + opts: CursorOptions, + ) -> Result, errors::LifetimeError> { + let dir = opts.direction.into(); + let query = opts.query.as_ref(); + let request = self + .inner + .open_cursor_with_range_and_direction(&query, dir)?; + Ok(CursorStream::new(StreamingRequest::new( + request, + opts.bubble_errors, + ))) } - fn raw(&self) -> &IdbObjectStore { - &self.inner - } + // Always valid (read only) - /// Get whether this object store uses an auto-incrementing key - pub fn auto_increment(&self) -> bool { - self.raw().auto_increment() + /// Whether this object store uses an auto-incrementing key + pub fn is_auto_increment(&self) -> bool { + self.inner.auto_increment() } /// Get a list containing all the names of indices on this object store. pub fn index_names(&self) -> StringList { - StringList::new(self.raw().index_names()) + StringList::new(self.inner.index_names()) } /// Get the key path for the object store. // Note: return value should be either null, a DOMString, or a sequence (from w3 // spec) - pub fn key_path(&self) -> KeyPath { - let key_path = self.raw().key_path().unwrap_throw(); + pub fn key_path_inner(&self) -> KeyPath { + let key_path = self.inner.key_path().unwrap_unreachable(); if key_path.is_null() { KeyPath::None } else if let Some(key_path) = key_path.as_string() { @@ -270,7 +226,7 @@ impl ObjectStoreReadOnly { let mut out = vec![]; for val in &key_path { - out.push(val.unwrap_throw().as_string().unwrap_throw()); + out.push(val.unwrap_unreachable().as_string().unwrap_unreachable()); } KeyPath::Sequence(out) } @@ -278,15 +234,21 @@ impl ObjectStoreReadOnly { /// The name of the object store. pub fn name(&self) -> String { - self.raw().name() + self.inner.name() } /// Count the number of records in the object store. - // TODO optional query argument - `count_query` function? - pub async fn count(&self) -> Result { - let result = Request::new(self.raw().count().map_err(errors::CountError::from)?, false) - .await - .map_err(errors::CountError::from)?; + /// + /// To count all objects pass `&Query::ALL`. + pub async fn count(&self, query: &Query) -> Result { + let result = Request::new( + self.inner + .count_with_key(query.as_ref()) + .map_err(errors::CountError::from)?, + false, + ) + .await + .map_err(errors::CountError::from)?; let result = result.as_f64().expect_throw("unreachable"); // From reading MDN it seems indexeddb cannot handle counts more than 2^32-1. if result <= u32::MAX.into() { @@ -297,88 +259,266 @@ impl ObjectStoreReadOnly { } /// Get an object from the object store by searching for the given key. - pub fn get(&self, key: K) -> impl Future, errors::GetError>> - where - Key: TryFrom, - V: DeserializeOwned, - { - fn get_inner( - this: &ObjectStoreReadOnly, - key: Key, - ) -> impl Future> { - let request = this.raw().get(&key.0).map_err(errors::GetError::from); - async move { - let request = Request::new(request?, false); - request.await.map_err(errors::GetError::from) - } - } - - let key = match Key::try_from(key) { - Ok(key) => key, - Err(_) => return Either::Left(ready(Err(errors::GetError::InvalidKey))), - }; - Either::Right(get_inner(self, key).map(|output| { - serde_wasm_bindgen::from_value(output?).map_err(errors::GetError::Deserialize) - })) + pub async fn get_raw(&self, key: Key) -> Result { + let request = self.inner.get(&key)?; + Ok(Request::new(request, false).await?) } /// Get an object from the object store by searching for the given key. /// - /// The result will be need to be deserialized from a javascript array, so you should use a - /// type like `Vec` to deserialize into. The reason we don't return a `Vec` is that you - /// might want to use a different collection, for example `im::Vector` from the - /// [`im`](https://crates.io/crates/im) crate, or `futures_signals::SignalVec::MutableVec` from - /// the [`futures_signals`](https://crates.io/crates/futures_signals) crate - pub async fn get_all(&self) -> Result + /// Automatically deserializes the result. + pub async fn get( + &self, + key: Key, + ) -> Result, errors::DeSerialize> where - V: DeserializeOwned, + V: for<'de> Deserialize<'de>, { - Request::new(self.raw().get_all().map_err(errors::GetError::from)?, false) - .await - .map_err(errors::GetError::from) - .and_then(|val| { - serde_wasm_bindgen::from_value(val).map_err(errors::GetError::Deserialize) - }) + Ok(serde_wasm_bindgen::from_value( + self.get_raw(key) + .await + .map_err(errors::DeSerialize::Other)?, + )?) + } + + /// Get all objects from the object store matching the given query. + /// + /// Use `Query::ALL` to get all values. + /// + /// The second argument is the maximum number of objects to return. If `None`, all matching + /// objects will be returned. + pub async fn get_all_raw( + &self, + query: &Query, + limit: Option, + ) -> Result { + let request = match limit { + Some(limit) => self + .inner + .get_all_with_key_and_limit(query.as_ref(), limit)?, + None => self.inner.get_all_with_key(query.as_ref())?, + }; + Ok(Request::new(request, false).await?) } - /// Open a cursor into the object store. + /// Get a sequence of values /// - /// This returns a builder - call `build` on it to submit the request. Defaults to iterating - /// over all values in the store, going forwards. + /// The user should choose a collection type `C` that can deserialize a sequence of values (for + /// example `Vec` from the standard library). /// - /// # Examples + /// Use `Query::ALL` to get all values. /// - /// > For all examples assume `store` is an open object store + /// The second argument is the maximum number of objects to return. If `None`, all matching + /// objects will be returned. + pub async fn get_all( + &self, + query: &Query, + limit: Option, + ) -> Result> + where + C: for<'de> Deserialize<'de>, + { + Ok(serde_wasm_bindgen::from_value( + self.get_all_raw(query, limit) + .await + .map_err(errors::DeSerialize::Other)?, + )?) + } + + /// Open an index with the given name. /// - /// Iterate over all records in ascending order (like [`get_all`], but doesn't require holding - /// all records in memory at once) + /// Returns `None` if no index with the given name exists. + pub fn index(&self, name: &str) -> Result>, errors::LifetimeError> { + match self.inner.index(name) { + Ok(idx) => Ok(Some(Index::new(idx))), + Err(e) => { + let e = errors::LifetimeError::from(e); + if matches!( + &e, + errors::LifetimeError::Unexpected(msg) if msg.as_str() == "NotFoundError") + { + Ok(None) + } else { + Err(e) + } + } + } + } +} + +impl ObjectStore { + /// Changes the object store's name. /// - /// ```no_run - /// use futures::StreamExt; + /// # Panics /// - /// let mut iter = store.open_cursor().build(); - /// while let Some(object) = store.next().await { - /// // do something with the object - /// } - /// ``` - pub fn open_cursor(&self) -> OpenCursor { - OpenCursor::new(self) + /// Currently this method will panic on error. If/when [this wasm-bindgen patch](https://github.com/rustwasm/wasm-bindgen/pull/2852) + /// lands errors will be returned instead. Because of the return type, the change will be + /// backwards-compatible. + pub fn set_name(&self, new_name: &str) -> Result<(), errors::SetNameError> { + self.set_name_inner(new_name) + } + + /// Create a new index for the object store. + pub fn create_index( + &self, + name: &str, + opts: IndexOptions, + ) -> Result, errors::CreateIndexError> { + self.create_index_inner(name, opts) + } + + /// Delete the index with the given name. + pub fn delete_index(&self, name: &str) -> Result<(), errors::DeleteIndexError> { + self.delete_index_inner(name) + } +} + +macro_rules! impl_ReadWrite { + ($ty:ty) => { + impl $ty { + /// Add an object to the database. + /// + /// This method returns a future, but always tries to add the object irrespective of whether + /// the future is ever polled. If `bubble_errors = true` any errors returned here will also + /// cause the transaction to abort. + pub async fn add_raw( + &self, + value: &JsValue, + key: Option, + bubble_errors: bool, + ) -> Result<(), errors::AddError> { + self.add_raw_inner(value, key, bubble_errors).await + } + + /// Add an arbitrary object to the database using serde to serialize it to a JsValue. + pub async fn add( + &self, + value: &(impl Serialize + ?Sized), + key: Option, + bubble_errors: bool, + ) -> Result<(), errors::AddError> { + self.add_inner(value, key, bubble_errors).await + } + + /// Update an object in the database using serde to serialize it to a JsValue. + pub async fn put_raw( + &self, + value: &JsValue, + key: Option, + bubble_errors: bool, + ) -> Result<(), errors::AddError> { + self.put_raw_inner(value, key, bubble_errors).await + } + + /// Update an object in the database using serde to serialize it to a JsValue. + pub async fn put( + &self, + value: &(impl Serialize + ?Sized), + key: Option, + bubble_errors: bool, + ) -> Result<(), errors::AddError> { + self.put_inner(value, key, bubble_errors).await + } + + /// Delete all objects in this object store. + /// + /// This method returns a future, but always tries to add the object irrespective of whether + /// the future is ever polled. + /// + /// If `bubble_errors = true` then an error here will also cause the transaction to abort, + /// whether the error is handled or not. + pub fn clear( + &self, + bubble_errors: bool, + ) -> impl Future> { + self.clear_inner(bubble_errors) + } + + /// Delete records from the store that match the given key. + pub fn delete( + &self, + key: impl Into, + bubble_errors: bool, + ) -> impl Future> { + self.delete_inner(key.into(), bubble_errors) + } + + /// Iterate over records in the store using a cursor. + pub fn cursor( + &self, + opts: CursorOptions, + ) -> Result, errors::LifetimeError> { + self.cursor_inner(opts) + } + } + }; +} + +impl_ReadWrite!(ObjectStore); +impl_ReadWrite!(ObjectStore); + +impl ObjectStore { + /// Iterate over records in the store using a cursor. + pub fn cursor( + &self, + opts: CursorOptions, + ) -> Result, errors::LifetimeError> { + self.cursor_inner(opts) + } +} + +/// A builder object for creating an index. +/// +/// Once you have set the options you require, call `build` to run the operation. +#[derive(Debug)] +pub struct IndexOptions { + key_path: JsValue, + params: IdbIndexParameters, +} + +impl IndexOptions { + /// The default options + pub fn new() -> Self { + Self { + key_path: JsValue::UNDEFINED, + params: IdbIndexParameters::new(), + } + } + + /// Set the key path for the index. + pub fn key_path(mut self, key_path: impl IntoKeyPath) -> Self { + self.key_path = key_path.into_jsvalue(); + self + } + + /// If `true`, the index will not allow duplicate values for a single key. + pub fn unique(mut self, yes: bool) -> Self { + self.params.unique(yes); + self + } + + /// If `true`, the index will add an entry in the index for each array element when the + /// *keyPath* resolves to an `Array`. If `false`, it will add one single entry containing the + /// `Array`. + pub fn multi_entry(mut self, yes: bool) -> Self { + self.params.multi_entry(yes); + self } + + // TODO potentially add `locale` in the future. } -/// Builder struct to open a cursor +/// Options when opening a cursor. #[derive(Debug)] -pub struct OpenCursor<'a> { - store: &'a ObjectStoreReadOnly, +pub struct CursorOptions { query: Query, direction: CursorDirection, bubble_errors: bool, } -impl<'store> OpenCursor<'store> { - fn new(store: &'store ObjectStoreReadOnly) -> Self { +impl CursorOptions { + fn new() -> Self { Self { - store, query: Query::ALL, direction: CursorDirection::Next, bubble_errors: true, @@ -404,18 +544,15 @@ impl<'store> OpenCursor<'store> { self } - /// Execute the request and return an object implementing `Stream>`. - pub fn build(self) -> Result { - let store = self.store.raw(); - let dir = self.direction.into(); - let request = match self.query.inner.as_ref() { - Some(range) => store.open_cursor_with_range_and_direction(&range, dir), - None => store.open_cursor_with_range_and_direction(&JsValue::UNDEFINED, dir), - } - .map_err(errors::GetError::from)?; - Ok(CursorStream::new(StreamingRequest::new( - request, - self.bubble_errors, - ))) + /// Whether errors should abort the whole transaction. + pub fn bubble_errors(mut self, bubble_errors: bool) -> Self { + self.bubble_errors = bubble_errors; + self + } +} + +impl Default for CursorOptions { + fn default() -> Self { + Self::new() } } diff --git a/crates/storage/src/indexed_db/transaction.rs b/crates/storage/src/indexed_db/transaction.rs index 61ec0f7e..7d7ebe16 100644 --- a/crates/storage/src/indexed_db/transaction.rs +++ b/crates/storage/src/indexed_db/transaction.rs @@ -1,105 +1,33 @@ -use super::{errors, ObjectStoreDuringUpgrade, ObjectStoreReadOnly, ObjectStoreReadWrite}; -use js_sys::{Object, Reflect}; +use super::{errors, ObjectStore}; +use js_sys::Reflect; use once_cell::sync::Lazy; -use std::ops::Deref; -use wasm_bindgen::{prelude::*, throw_str, JsCast}; -use web_sys::{IdbObjectStore, IdbTransaction}; +use std::marker::PhantomData; +use wasm_bindgen::{prelude::*, JsCast}; +use web_sys::IdbTransaction; -/// An in-progress database upgrade transaction +/// A database transaction. /// -/// Please do not stash the transaction. Once our code yields (e.g. over an await point that -/// doesn't involve a database method) the transaction will autocommit, and further attempts to use -/// it will return an error. +/// All interaction with a database happens in a transaction. #[derive(Debug)] -pub struct TransactionDuringUpgrade { - inner: TransactionReadWrite, -} - -impl TransactionDuringUpgrade { - pub(crate) fn new(inner: IdbTransaction) -> Self { - Self { - inner: TransactionReadWrite::new(inner), - } - } - - /// Fetch an object store - /// - /// Note this deliberately shadows [`TransactionReadWrite::object_store`], providing access to - /// it through `ObjectStoreDuringUpgrade::deref`. - pub fn object_store<'trans>( - &'trans self, - name: &str, - ) -> Result { - object_store(self.raw(), name).map(ObjectStoreDuringUpgrade::new) - } -} - -impl Deref for TransactionDuringUpgrade { - type Target = TransactionReadWrite; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -/// An in-progress database transaction -/// -/// Please do not stash the transaction. Once our code yields (e.g. over an await point) -#[derive(Debug)] -pub struct TransactionReadWrite { - inner: TransactionReadOnly, +pub struct Transaction { + inner: IdbTransaction, + ty: PhantomData, } -impl TransactionReadWrite { +impl Transaction { pub(crate) fn new(inner: IdbTransaction) -> Self { Self { - inner: TransactionReadOnly::new(inner), + inner, + ty: PhantomData, } } - /// Fetch an object store - /// - /// Note this deliberately shadows [`TransactionReadOnly::object_store`], providing access to - /// it through `Deref`. - pub fn object_store( - &self, - name: &str, - ) -> Result { - object_store(self.raw(), name).map(ObjectStoreReadWrite::new) - } -} - -impl Deref for TransactionReadWrite { - type Target = TransactionReadOnly; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -/// An in-progress database transaction -/// -/// Please do not stash the transaction. Once our code yields (e.g. over an await point) -#[derive(Debug)] -pub struct TransactionReadOnly { - inner: IdbTransaction, -} - -impl TransactionReadOnly { - pub(crate) fn new(inner: IdbTransaction) -> Self { - Self { inner } - } - - fn raw(&self) -> &IdbTransaction { - &self.inner - } - - /// Fetch an object store - pub fn object_store( - &self, - name: &str, - ) -> Result { - object_store(self.raw(), name).map(ObjectStoreReadOnly::new) + /// Open an object store. + pub fn object_store(&self, name: &str) -> Result, errors::ObjectStoreError> { + self.inner + .object_store(name) + .map(ObjectStore::new) + .map_err(errors::ObjectStoreError::from) } /// This function commits the transaction if supported. @@ -114,25 +42,6 @@ impl TransactionReadOnly { } } -impl Drop for TransactionReadOnly { - fn drop(&mut self) { - // indexeddb already does auto-commit. This tells it we've done so it can potentially - // commit the transaction earlier - // TODO if we re-enable this we need to force users to keep the transaciton alive (e.g. by - // making object stores borrow from it). - //self.commit(); - } -} - -fn object_store( - trans: &IdbTransaction, - name: &str, -) -> Result { - trans - .object_store(name) - .map_err(errors::ObjectStoreError::from) -} - // Optional support for transaction.commit // TODO remove logging static SUPPORTS_COMMIT: Lazy = Lazy::new(|| { diff --git a/crates/storage/src/indexed_db/util.rs b/crates/storage/src/indexed_db/util.rs index 4c5508c7..4877b56e 100644 --- a/crates/storage/src/indexed_db/util.rs +++ b/crates/storage/src/indexed_db/util.rs @@ -1,6 +1,16 @@ -use wasm_bindgen::UnwrapThrowExt; +use wasm_bindgen::{intern, UnwrapThrowExt}; use web_sys::DomStringList; +pub(crate) trait UnreachableExt: UnwrapThrowExt { + fn unwrap_unreachable(self) -> T; +} + +impl> UnreachableExt for R { + fn unwrap_unreachable(self) -> T { + self.expect_throw(intern("unreachable")) + } +} + /// A wrapper around [`web_sys::DomStringList`] for easy iteration. #[derive(Debug)] pub struct StringList { diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 067bbef0..750bc549 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -13,6 +13,7 @@ use wasm_bindgen_futures::JsFuture; use crate::errors::js_to_error; use errors::StorageError; use gloo_utils::window; +use js_sys::Reflect; use serde_json::{Map, Value}; #[macro_use] @@ -101,6 +102,14 @@ pub trait Storage { } } +/// Have we been granted permission to store data indefinitely? +pub async fn is_persisted() -> bool { + JsFuture::from(storage_manager().persisted().unwrap_throw()) + .await + .unwrap_throw() + .is_truthy() +} + /// Request that stored data be persisted and not reclaimed unless the user specifically clears /// their storage. /// @@ -112,6 +121,35 @@ pub async fn persist() -> bool { .is_truthy() } +/// How much quota do we have, and how much have we used? +pub async fn estimate() -> Quota { + let raw = JsFuture::from(storage_manager().estimate().unwrap_throw()) + .await + .unwrap_throw(); + // The casts here are lossy, but the values are approximate anyway. + let total = Reflect::get(&raw, &JsValue::from_str(wasm_bindgen::intern("quota"))) + .unwrap_throw() + .as_f64() + .unwrap_throw() as u64; + let used = Reflect::get(&raw, &JsValue::from_str(wasm_bindgen::intern("usage"))) + .unwrap_throw() + .as_f64() + .unwrap_throw() as u64; + Quota { total, used } +} + +/// Approximate amount of storage available, and used. +/// +/// See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/estimate) for more +/// details. +#[derive(Debug, Copy, Clone)] +pub struct Quota { + /// The total space available to this origin. + pub total: u64, + /// The space used by this origin. + pub used: u64, +} + /// Get the user agent's storage manager instance fn storage_manager() -> web_sys::StorageManager { window().navigator().storage() diff --git a/examples/storage/README.md b/examples/storage/README.md deleted file mode 100644 index 5f0dae63..00000000 --- a/examples/storage/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Clock example - -This is a simple example showcasing the Gloo timers. - -First, [install wasm-pack](https://rustwasm.github.io/wasm-pack/installer/) if needed. - -Then build the clock example by running `wasm-pack build --target no-modules` and open your browser to load `index.html`. \ No newline at end of file diff --git a/examples/storage/index.html b/examples/storage/index.html deleted file mode 100644 index e279810f..00000000 --- a/examples/storage/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - Storage - - -
- - - diff --git a/examples/storage/src/lib.rs b/examples/storage/src/lib.rs deleted file mode 100644 index b3fd540f..00000000 --- a/examples/storage/src/lib.rs +++ /dev/null @@ -1,175 +0,0 @@ -use dominator::{events, html, text, text_signal, with_node, Dom}; -use futures_signals::{ - signal::{Mutable, SignalExt}, - signal_vec::{MutableVec, SignalVecExt}, -}; -use gloo::storage::indexed_db as idb; -use serde::{Deserialize, Serialize}; -use smartstring::alias::String as SmartString; -use std::sync::Arc; -use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::spawn_local; - -type ArcStr = Arc; - -#[wasm_bindgen(start)] -pub fn main() { - console_error_panic_hook::set_once(); - - let app = App::new(); - let document = web_sys::window().unwrap_throw().document().unwrap_throw(); - - let el = document.get_element_by_id("app").unwrap_throw(); - - // render the date, then set it to re-render every second. - spawn_local(use_db(el)); -} - -enum App { - Loading, - Loaded(AppState), - Error(String), -} - -impl App { - fn new() -> Mutable { - let this = Mutable::new(App::Loading); - spawn_local({ - let this = this.clone(); - async move { - let state = AppState::new().await; - this.set(App::Loaded(state)) - } - }); - this - } - - fn render_mutable(this: Mutable) -> Dom { - html!("div", { - .attr("id", "app") - .child_signal(this.signal_ref(|app| Some(App::render(app)))) - }) - } - - fn render(&self) -> Dom { - match self { - App::Loading => text("Loading"), - App::Loaded(state) => state.render(), - App::Error(msg) => text(&format!("Error: {}", msg)), - } - } -} - -struct AppState { - db: idb::Database, - user: Person, - people: MutableVec, - new_person: Mutable, -} - -impl AppState { - async fn new() -> Self { - let db = idb::Database::open( - "mydb", - 1, - |db| { - let store = db - .create_object_store("people") - .auto_increment(true) - .key_path("id") - .build() - .unwrap(); - let _ = store.add(&NewPerson::new("Joe", "Bloggs"), None, true); - }, - true, - ) - .await - .unwrap(); - let trans = db.transaction_readonly(&["people"]).unwrap(); - let people_store = trans.object_store("people").unwrap(); - let user = people_store.get(1.).await.unwrap().unwrap(); - let people = people_store.get_all().await.unwrap(); - AppState { - db, - user, - people, - new_person: Mutable::new(NewPerson::default()), - } - } - - fn render(&self) -> Dom { - html!("div", { - .child(render_people(&self.people)) - .child(NewPerson::render_form(self.new_person.clone())) - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Person { - id: u64, - first_name: SmartString, - last_name: SmartString, -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -struct NewPerson { - first_name: SmartString, - last_name: SmartString, -} - -impl NewPerson { - fn new(first_name: &str, last_name: &str) -> Self { - Self { - first_name: first_name.into(), - last_name: last_name.into(), - } - } - - fn render_form(this: Mutable) -> Dom { - html!("label", { - .children(&mut [ - text("first name ("), - text_signal(this.signal_cloned().map(|p| p.first_name)), - text(") "), - html!("input" => web_sys::HtmlInputElement, { - .with_node!(input => { - .attr("type", "text") - .attr_signal("value", this.signal_cloned().map(|person| person.first_name)) - .event({ - let this = this.clone(); - move |_evt: events::Input| { - this.lock_mut().first_name = input.value().into(); - } - }) - }) - }), - html!("input" => web_sys::HtmlInputElement, { - .with_node!(input => { - .attr("type", "text") - .attr_signal("value", this.signal_cloned().map(|person| person.last_name)) - .event({ - let this = this.clone(); - move |_evt: events::Input| { - this.lock_mut().last_name = input.value().into(); - } - }) - }) - }) - ]) - }) - } -} - -fn render_people(people: &MutableVec) -> Dom { - html!("div", { - .children_signal_vec(people.signal_vec_cloned().map(|person| { - })) - }) -} - -/// Render the date with the `:` flashing on and off every second into `el`. -async fn use_db(el: web_sys::Element) { - let app = App::new(); - dominator::append_dom(&dominator::get_id("app"), App::render_mutable(app)); -} diff --git a/examples/storage/.gitignore b/examples/todomvc/.gitignore similarity index 100% rename from examples/storage/.gitignore rename to examples/todomvc/.gitignore diff --git a/examples/storage/Cargo.toml b/examples/todomvc/Cargo.toml similarity index 74% rename from examples/storage/Cargo.toml rename to examples/todomvc/Cargo.toml index 43451c41..9338bf7d 100644 --- a/examples/storage/Cargo.toml +++ b/examples/todomvc/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "storage" +name = "todomvc" version = "0.1.0" authors = ["Rust and WebAssembly Working Group"] edition = "2021" @@ -17,6 +17,12 @@ futures-signals = { version = "0.3.24", features = ["serde"] } dominator = { version = "0.5.26", package = "dominator2", features = ["smartstring"] } serde = { version = "1", features = ["derive", "rc"] } smartstring = { version = "1", features = ["serde"] } +uuid = { version = "0.8.2", features = ["serde", "v4", "wasm-bindgen"] } +chrono = { version = "0.4.19", features = ["serde", "wasmbind"] } +serde_json = "1.0.79" +discard = "1.0.4" +serde-wasm-bindgen = "0.4.2" +futures = "0.3.21" [dependencies.web-sys] version = "0.3.19" diff --git a/examples/todomvc/README.md b/examples/todomvc/README.md new file mode 100644 index 00000000..760ef0b6 --- /dev/null +++ b/examples/todomvc/README.md @@ -0,0 +1,9 @@ +# TodoMVC example + +This is an example to show the use of the IndexedDB wrapper in `gloo-storage`. + +First, [install wasm-pack](https://rustwasm.github.io/wasm-pack/installer/) if needed. + +Then build the example by running `wasm-pack build --target web` and open your browser to load `index.html`. + +This example is uses [`dominator`](https://crates.io/crates/dominator) to update the DOM. It's a fairly low-level library to help with keeping the DOM and our app state in sync. You don't need to understand it to see how IndexedDB is used in this library (just search for `idb` to see the library in action). diff --git a/examples/todomvc/index.css b/examples/todomvc/index.css new file mode 100644 index 00000000..d8be205a --- /dev/null +++ b/examples/todomvc/index.css @@ -0,0 +1,376 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 300; +} + +:focus { + outline: 0; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +.toggle-all { + text-align: center; + border: none; /* Mobile Safari */ + opacity: 0; + position: absolute; +} + +.toggle-all + label { + width: 60px; + height: 34px; + font-size: 0; + position: absolute; + top: -52px; + left: -13px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.toggle-all + label:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked + label:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 12px 16px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle { + opacity: 0; +} + +.todo-list li .toggle + label { + /* + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ + */ + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: center left; +} + +.todo-list li .toggle:checked + label { + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); +} + +.todo-list li label { + word-break: break-all; + padding: 15px 15px 15px 60px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: '×'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + .filters { + bottom: 10px; + } +} diff --git a/examples/todomvc/index.html b/examples/todomvc/index.html new file mode 100644 index 00000000..dccb7f8a --- /dev/null +++ b/examples/todomvc/index.html @@ -0,0 +1,20 @@ + + + + + + + Todo MVC + + +
+
+

Double-click to edit a todo

+

Based on TodoMVC

+
+ + + diff --git a/examples/todomvc/src/app.rs b/examples/todomvc/src/app.rs new file mode 100644 index 00000000..263f9d95 --- /dev/null +++ b/examples/todomvc/src/app.rs @@ -0,0 +1,358 @@ +use dominator::{ + clone, events, html, link, routing, text, text_signal, with_node, Dom, EventOptions, +}; +use futures::StreamExt; +use futures_signals::signal::{Broadcaster, Mutable, Signal, SignalExt}; +use futures_signals::signal_vec::{MutableVec, SignalVec, SignalVecExt}; +use gloo::storage::indexed_db as idb; +use std::{collections::HashSet, pin::Pin, sync::Arc}; +use wasm_bindgen::prelude::*; +use web_sys::{HtmlInputElement, Url}; + +use crate::todo::Todo; +use crate::util::trim; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Route { + Active, + Completed, + All, +} + +impl Route { + // This could use more advanced URL parsing, but it isn't needed + pub fn from_url(url: &str) -> Self { + let url = Url::new(&url).unwrap_throw(); + match url.hash().as_str() { + "#/active" => Route::Active, + "#/completed" => Route::Completed, + _ => Route::All, + } + } + + pub fn to_url(&self) -> &'static str { + match self { + Route::Active => "#/active", + Route::Completed => "#/completed", + Route::All => "#/", + } + } +} + +impl Default for Route { + fn default() -> Self { + // Create the Route based on the current URL + Self::from_url(&routing::url().lock_ref()) + } +} + +thread_local! { + pub static ROUTE: Broadcaster>>> + = Broadcaster::new(routing::url().signal_ref(|s| Route::from_url(s)).boxed_local()); +} + +#[derive(Debug, Clone)] +pub enum App { + Loading, + Running(Arc), + Error(Arc), +} + +#[derive(Debug)] +pub struct AppInner { + pub db: idb::Database, + new_todo_title: Mutable, + todo_list: MutableVec>, +} + +impl App { + pub fn new() -> Arc> { + Arc::new(Mutable::new(Self::Loading)) + } + + pub fn init(app: &Arc>) { + let app = (*app).clone(); + wasm_bindgen_futures::spawn_local(async move { + let db = handle_err!( + app, + idb::Database::open( + "gloo-indexedb-todomvc", + 1, + |db| { + let todos_store = db + .create_object_store( + "todos", + idb::ObjectStoreOptions::new().key_path("id"), + ) + .expect_throw("creating database"); + todos_store + .create_index( + "todos_created_at", + idb::IndexOptions::new().key_path("created_at"), + ) + .expect_throw("creating database"); + }, + true + ) + .await + ); + + // Get existing TODOs + let trans = db.transaction_readonly(&["todos"]).unwrap_throw(); + let store = trans.object_store("todos").unwrap_throw(); + let created_at_idx = store + .index("todos_created_at") + .unwrap_throw() + .unwrap_throw(); + app.replace(App::Running(Arc::new(AppInner { + db, + new_todo_title: Mutable::new("".into()), + todo_list: created_at_idx + .get_all(&idb::Query::ALL, None) + .await + .unwrap_throw(), + }))); + }) + } + + pub fn render(self) -> Dom { + match self { + App::Loading => text("loading"), + App::Running(inner) => inner.render(), + App::Error(msg) => text(&msg), + } + } +} + +impl AppInner { + fn create_new_todo(&self) { + let mut title = self.new_todo_title.lock_mut(); + + // Only create a new Todo if the text box is not empty + let todo = if let Some(trimmed) = trim(&title) { + let todo = Todo::new(trimmed.to_string()); + *title = "".to_string(); + todo + } else { + return; + }; + + let todo_js = serde_wasm_bindgen::to_value(&todo).unwrap_throw(); + self.todo_list.lock_mut().push_cloned(todo); + + let trans = self.db.transaction_readwrite(&["todos"]).unwrap_throw(); + wasm_bindgen_futures::spawn_local(async move { + let store = trans.object_store("todos").unwrap_throw(); + store.add_raw(&todo_js, None, true).await.unwrap_throw(); + }); + } + + pub fn remove_todo(&self, todo: &Todo) { + self.todo_list.lock_mut().retain(|x| **x != *todo); + let trans = self.db.transaction_readwrite(&["todos"]).unwrap_throw(); + let id = todo.id.to_string(); + wasm_bindgen_futures::spawn_local(async move { + let store = trans.object_store("todos").unwrap_throw(); + store.delete(&*id, true).await.unwrap_throw(); + }); + } + + fn remove_all_completed_todos(&self) { + let mut ids = HashSet::new(); + self.todo_list.lock_mut().retain(|todo| { + if todo.completed.get() == false { + true + } else { + ids.insert(todo.id); + false + } + }); + let trans = self.db.transaction_readwrite(&["todos"]).unwrap_throw(); + wasm_bindgen_futures::spawn_local(async move { + let store = trans.object_store("todos").unwrap_throw(); + for id in ids { + let id = id.to_string(); + store.delete(&*id, true).await.unwrap_throw(); + } + }); + } + + fn set_all_todos_completed(&self, checked: bool) { + for todo in self.todo_list.lock_ref().iter() { + todo.completed.set_neq(checked); + } + + // Change `completed` of all todos in the db. + let trans = self.db.transaction_readwrite(&["todos"]).unwrap_throw(); + wasm_bindgen_futures::spawn_local(async move { + let store = trans.object_store("todos").unwrap_throw(); + let mut cursor = store.cursor(idb::CursorOptions::default()).unwrap_throw(); + while let Some(obj) = cursor.next().await { + let obj = obj.unwrap_throw(); + let val: Todo = obj.value().unwrap_throw(); + val.completed.set(true); + obj.update(&val, true).await.unwrap_throw(); + } + }); + } + + fn completed(&self) -> impl SignalVec { + self.todo_list + .signal_vec_cloned() + .map_signal(|todo| todo.completed.signal()) + } + + fn completed_len(&self) -> impl Signal { + self.completed().filter(|completed| *completed).len() + } + + fn not_completed_len(&self) -> impl Signal { + self.completed().filter(|completed| !completed).len() + } + + fn has_todos(&self) -> impl Signal { + self.todo_list + .signal_vec_cloned() + .len() + .map(|len| len > 0) + .dedupe() + } + + fn render_header(app: Arc) -> Dom { + html!("header", { + .class("header") + .children(&mut [ + html!("h1", { + .text("todos") + }), + + html!("input" => HtmlInputElement, { + .focused(true) + .class("new-todo") + .attr("placeholder", "What needs to be done?") + .prop_signal("value", app.new_todo_title.signal_cloned()) + + .with_node!(element => { + .event(clone!(app => move |_: events::Input| { + app.new_todo_title.set_neq(element.value()); + })) + }) + + .event_with_options(&EventOptions::preventable(), clone!(app => move |event: events::KeyDown| { + if event.key() == "Enter" { + event.prevent_default(); + app.create_new_todo(); + } + })) + }), + ]) + }) + } + + fn render_main(app: Arc) -> Dom { + html!("section", { + .class("main") + + .visible_signal(app.has_todos()) + + .children(&mut [ + html!("input" => HtmlInputElement, { + .class("toggle-all") + .attr("id", "toggle-all") + .attr("type", "checkbox") + .prop_signal("checked", app.not_completed_len().map(|len| len == 0).dedupe()) + + .with_node!(element => { + .event(clone!(app => move |_: events::Change| { + app.set_all_todos_completed(element.checked()); + })) + }) + }), + + html!("label", { + .attr("for", "toggle-all") + .text("Mark all as complete") + }), + + html!("ul", { + .class("todo-list") + .children_signal_vec(app.todo_list.signal_vec_cloned() + .map(clone!(app => move |todo| Todo::render(todo, app.clone())))) + }), + ]) + }) + } + + fn render_button(text: &str, route: Route) -> Dom { + html!("li", { + .children(&mut [ + link!(route.to_url(), { + .text(text) + .class_signal("selected", ROUTE.with(|r| r.signal().map(move |x| x == route))) + }) + ]) + }) + } + + fn render_footer(app: Arc) -> Dom { + html!("footer", { + .class("footer") + + .visible_signal(app.has_todos()) + + .children(&mut [ + html!("span", { + .class("todo-count") + + .children(&mut [ + html!("strong", { + .text_signal(app.not_completed_len().map(|len| len.to_string())) + }), + + text_signal(app.not_completed_len().map(|len| { + if len == 1 { + " item left" + } else { + " items left" + } + })), + ]) + }), + + html!("ul", { + .class("filters") + .children(&mut [ + Self::render_button("All", Route::All), + Self::render_button("Active", Route::Active), + Self::render_button("Completed", Route::Completed), + ]) + }), + + html!("button", { + .class("clear-completed") + + // Show if there is at least one completed item. + .visible_signal(app.completed_len().map(|len| len > 0).dedupe()) + + .event(clone!(app => move |_: events::Click| { + app.remove_all_completed_todos(); + })) + + .text("Clear completed") + }), + ]) + }) + } + + pub fn render(self: Arc) -> Dom { + html!("section", { + .class("todoapp") + + .children(&mut [ + Self::render_header(self.clone()), + Self::render_main(self.clone()), + Self::render_footer(self.clone()), + ]) + }) + } +} diff --git a/examples/todomvc/src/lib.rs b/examples/todomvc/src/lib.rs new file mode 100644 index 00000000..3bbdcc7d --- /dev/null +++ b/examples/todomvc/src/lib.rs @@ -0,0 +1,38 @@ +use crate::app::App; +use discard::Discard; +use dominator::DomHandle; +use futures_signals::signal::SignalExt; +use std::sync::{Arc, Mutex}; +use wasm_bindgen::prelude::*; + +#[macro_use] +mod macros; +mod app; +mod todo; +mod util; + +#[wasm_bindgen(start)] +pub fn main_js() { + console_error_panic_hook::set_once(); + + // Request our data be persisted + wasm_bindgen_futures::spawn_local(async move { + gloo::storage::persist().await; + }); + + let app = App::new(); + App::init(&app); + let dom: Arc>> = Arc::new(Mutex::new(None)); + wasm_bindgen_futures::spawn_local(app.signal_cloned().for_each(move |state| { + //gloo::console::console_dbg!(state); + let mut dom = dom.lock().unwrap(); + if let Some(dom) = dom.take() { + dom.discard(); + } + *dom = Some(dominator::append_dom( + &dominator::get_id("todoapp"), + state.render(), + )); + async move {} + })); +} diff --git a/examples/todomvc/src/macros.rs b/examples/todomvc/src/macros.rs new file mode 100644 index 00000000..6b9a7fb7 --- /dev/null +++ b/examples/todomvc/src/macros.rs @@ -0,0 +1,11 @@ +macro_rules! handle_err { + ($app:expr, $body:expr) => { + match $body { + Ok(v) => v, + Err(e) => { + $app.set(crate::app::App::Error(::std::sync::Arc::new(e.to_string()))); + return; + } + } + }; +} diff --git a/examples/todomvc/src/todo.rs b/examples/todomvc/src/todo.rs new file mode 100644 index 00000000..49c805d5 --- /dev/null +++ b/examples/todomvc/src/todo.rs @@ -0,0 +1,164 @@ +use chrono::{prelude::*, Utc}; +use dominator::{clone, events, html, with_node, Dom}; +use futures_signals::map_ref; +use futures_signals::signal::{Mutable, Signal, SignalExt}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; +use wasm_bindgen::prelude::*; +use web_sys::HtmlInputElement; + +use crate::app::{AppInner, Route, ROUTE}; +use crate::util::trim; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Todo { + pub id: Uuid, + pub created_at: DateTime, + pub title: Mutable, + pub completed: Mutable, + + #[serde(skip)] + editing: Mutable>, +} + +impl Todo { + pub fn new(title: String) -> Arc { + Arc::new(Self { + id: Uuid::new_v4(), + created_at: Utc::now(), + title: Mutable::new(title), + completed: Mutable::new(false), + editing: Mutable::new(None), + }) + } + + fn set_completed(&self, app: &AppInner, completed: bool) { + self.completed.set_neq(completed); + + let todo = (*self).clone(); + let trans = app.db.transaction_readwrite(&["todos"]).unwrap_throw(); + wasm_bindgen_futures::spawn_local(async move { + let store = trans.object_store("todos").unwrap_throw(); + store.put(&todo, None, true).await.unwrap_throw(); + }); + } + + fn remove(&self, app: &AppInner) { + app.remove_todo(&self); + } + + fn is_visible(&self) -> impl Signal { + (map_ref! { + let route = ROUTE.with(|r| r.signal()), + let completed = self.completed.signal() => + match *route { + Route::Active => !completed, + Route::Completed => *completed, + Route::All => true, + } + }) + .dedupe() + } + + fn is_editing(&self) -> impl Signal { + self.editing.signal_ref(|x| x.is_some()).dedupe() + } + + fn cancel_editing(&self) { + self.editing.set_neq(None); + } + + fn done_editing(&self, app: &AppInner) { + if let Some(title) = self.editing.replace(None) { + if let Some(title) = trim(&title) { + self.title.set_neq(title.to_string()); + } else { + app.remove_todo(&self); + } + } + } + + pub fn render(todo: Arc, app: Arc) -> Dom { + html!("li", { + .class_signal("editing", todo.is_editing()) + .class_signal("completed", todo.completed.signal()) + + .visible_signal(todo.is_visible()) + + .children(&mut [ + html!("div", { + .class("view") + .children(&mut [ + html!("input" => HtmlInputElement, { + .class("toggle") + .attr("type", "checkbox") + .prop_signal("checked", todo.completed.signal()) + + .with_node!(element => { + .event(clone!(todo, app => move |_: events::Change| { + todo.set_completed(&app, element.checked()); + })) + }) + }), + + html!("label", { + .event(clone!(todo => move |_: events::DoubleClick| { + todo.editing.set_neq(Some(todo.title.get_cloned())); + })) + + .text_signal(todo.title.signal_cloned()) + }), + + html!("button", { + .class("destroy") + .event(clone!(todo, app => move |_: events::Click| { + todo.remove(&app); + })) + }), + ]) + }), + + html!("input" => HtmlInputElement, { + .class("edit") + + .prop_signal("value", todo.editing.signal_cloned() + .map(|x| x.unwrap_or_else(|| "".to_owned()))) + + .visible_signal(todo.is_editing()) + .focused_signal(todo.is_editing()) + + .with_node!(element => { + .event(clone!(todo => move |event: events::KeyDown| { + match event.key().as_str() { + "Enter" => { + element.blur().unwrap_throw(); + }, + "Escape" => { + todo.cancel_editing(); + }, + _ => {} + } + })) + }) + + .with_node!(element => { + .event(clone!(todo => move |_: events::Input| { + todo.editing.set_neq(Some(element.value())); + })) + }) + + .event(clone!(todo, app => move |_: events::Blur| { + todo.done_editing(&app); + })) + }), + ]) + }) + } +} + +impl PartialEq for Todo { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} diff --git a/examples/todomvc/src/util.rs b/examples/todomvc/src/util.rs new file mode 100644 index 00000000..624034e8 --- /dev/null +++ b/examples/todomvc/src/util.rs @@ -0,0 +1,10 @@ +#[inline] +pub fn trim(input: &str) -> Option<&str> { + let trimmed = input.trim(); + + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} From dcaf8043fcc3fede0f2753d05be4afafc00974b9 Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Wed, 20 Apr 2022 14:48:39 +0100 Subject: [PATCH 5/8] Tidy up atomics slightly. --- crates/storage/src/indexed_db/cursor.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/storage/src/indexed_db/cursor.rs b/crates/storage/src/indexed_db/cursor.rs index 881aab80..2ca5db0f 100644 --- a/crates/storage/src/indexed_db/cursor.rs +++ b/crates/storage/src/indexed_db/cursor.rs @@ -31,12 +31,13 @@ impl StreamState { } fn take(&self) -> bool { - if self.inner.load(Ordering::SeqCst) == 0 { - self.inner - .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() + if self + .inner + .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + true } else { - // Set to error unconditionally. self.inner.store(2, Ordering::SeqCst); false } From bc3ec8193940d7c5b67945f263376671c8355ffd Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Wed, 20 Apr 2022 15:03:59 +0100 Subject: [PATCH 6/8] Fix clippy --- crates/storage/Cargo.toml | 1 + crates/storage/src/indexed_db/key.rs | 16 ++++++++-------- crates/storage/src/indexed_db/object_store.rs | 8 +++++++- crates/storage/src/lib.rs | 1 + examples/todomvc/src/app.rs | 10 +++++----- examples/todomvc/src/lib.rs | 1 + examples/todomvc/src/todo.rs | 4 ++-- 7 files changed, 25 insertions(+), 16 deletions(-) diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 82b32ccb..eedde1ec 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -31,6 +31,7 @@ features = [ "Navigator", "StorageManager", "Storage", + "console", # indexeddb "DomException", "DomStringList", diff --git a/crates/storage/src/indexed_db/key.rs b/crates/storage/src/indexed_db/key.rs index 21770717..58b16fd1 100644 --- a/crates/storage/src/indexed_db/key.rs +++ b/crates/storage/src/indexed_db/key.rs @@ -42,8 +42,8 @@ where { fn into_jsvalue(self) -> JsValue { let arr = js_sys::Array::new(); - for i in 0..self.len() { - arr.push(&JsValue::from(self[i].as_ref())); + for itm in self { + arr.push(&JsValue::from(itm.as_ref())); } JsValue::from(arr) } @@ -56,7 +56,7 @@ impl IntoKeyPath for KeyPath { KeyPath::String(s) => JsValue::from(s), KeyPath::Sequence(multi) => multi .iter() - .map(|s| JsValue::from(s)) + .map(JsValue::from) .collect::() .into(), } @@ -218,7 +218,7 @@ impl Query { /// Create a range that will only match the given key. pub fn only(key: &Key) -> Self { - Self::new(IdbKeyRange::only(&key).expect_throw("unreachable")) + Self::new(IdbKeyRange::only(key).expect_throw("unreachable")) } /// Create a query from a given range. @@ -235,7 +235,7 @@ impl Query { pub fn from_range( lower: Option<(&Key, bool)>, upper: Option<(&Key, bool)>, - ) -> Result { + ) -> Result> { match (lower, upper) { (None, None) => return Ok(Self::ALL), (None, Some((upper, upper_open))) => { @@ -251,7 +251,7 @@ impl Query { } } .map(Self::new) - .map_err(|_| ()) + .map_err(|_| "invalid range".into()) } fn new(inner: IdbKeyRange) -> Self { @@ -262,14 +262,14 @@ impl Query { } impl TryFrom> for Query { - type Error = (); + type Error = Box; fn try_from(range: Range) -> Result { Self::from_range(Some((&range.start, false)), Some((&range.end, true))) } } impl TryFrom> for Query { - type Error = (); + type Error = Box; fn try_from(range: RangeInclusive) -> Result { Self::from_range(Some((range.start(), false)), Some((range.end(), false))) } diff --git a/crates/storage/src/indexed_db/object_store.rs b/crates/storage/src/indexed_db/object_store.rs index 6e1ee3b5..29b365a0 100644 --- a/crates/storage/src/indexed_db/object_store.rs +++ b/crates/storage/src/indexed_db/object_store.rs @@ -193,7 +193,7 @@ impl ObjectStore { let query = opts.query.as_ref(); let request = self .inner - .open_cursor_with_range_and_direction(&query, dir)?; + .open_cursor_with_range_and_direction(query, dir)?; Ok(CursorStream::new(StreamingRequest::new( request, opts.bubble_errors, @@ -476,6 +476,12 @@ pub struct IndexOptions { params: IdbIndexParameters, } +impl Default for IndexOptions { + fn default() -> Self { + Self::new() + } +} + impl IndexOptions { /// The default options pub fn new() -> Self { diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 750bc549..207fad9e 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unused_unit)] //! This crate provides wrappers for the //! [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API) //! diff --git a/examples/todomvc/src/app.rs b/examples/todomvc/src/app.rs index 263f9d95..0cff1056 100644 --- a/examples/todomvc/src/app.rs +++ b/examples/todomvc/src/app.rs @@ -22,7 +22,7 @@ pub enum Route { impl Route { // This could use more advanced URL parsing, but it isn't needed pub fn from_url(url: &str) -> Self { - let url = Url::new(&url).unwrap_throw(); + let url = Url::new(url).unwrap_throw(); match url.hash().as_str() { "#/active" => Route::Active, "#/completed" => Route::Completed, @@ -30,7 +30,7 @@ impl Route { } } - pub fn to_url(&self) -> &'static str { + pub fn as_url(&self) -> &'static str { match self { Route::Active => "#/active", Route::Completed => "#/completed", @@ -160,7 +160,7 @@ impl AppInner { fn remove_all_completed_todos(&self) { let mut ids = HashSet::new(); self.todo_list.lock_mut().retain(|todo| { - if todo.completed.get() == false { + if !todo.completed.get() { true } else { ids.insert(todo.id); @@ -286,7 +286,7 @@ impl AppInner { fn render_button(text: &str, route: Route) -> Dom { html!("li", { .children(&mut [ - link!(route.to_url(), { + link!(route.as_url(), { .text(text) .class_signal("selected", ROUTE.with(|r| r.signal().map(move |x| x == route))) }) @@ -351,7 +351,7 @@ impl AppInner { .children(&mut [ Self::render_header(self.clone()), Self::render_main(self.clone()), - Self::render_footer(self.clone()), + Self::render_footer(self), ]) }) } diff --git a/examples/todomvc/src/lib.rs b/examples/todomvc/src/lib.rs index 3bbdcc7d..42b7b3aa 100644 --- a/examples/todomvc/src/lib.rs +++ b/examples/todomvc/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unused_unit)] use crate::app::App; use discard::Discard; use dominator::DomHandle; diff --git a/examples/todomvc/src/todo.rs b/examples/todomvc/src/todo.rs index 49c805d5..dd2527ef 100644 --- a/examples/todomvc/src/todo.rs +++ b/examples/todomvc/src/todo.rs @@ -45,7 +45,7 @@ impl Todo { } fn remove(&self, app: &AppInner) { - app.remove_todo(&self); + app.remove_todo(self); } fn is_visible(&self) -> impl Signal { @@ -74,7 +74,7 @@ impl Todo { if let Some(title) = trim(&title) { self.title.set_neq(title.to_string()); } else { - app.remove_todo(&self); + app.remove_todo(self); } } } From 2a31db4f307a54526c639904bd0ba5c778469697 Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Mon, 2 May 2022 15:52:10 +0100 Subject: [PATCH 7/8] Split out `Request` into separate module. --- crates/storage/README.md | 227 +++++++++++++- crates/storage/src/indexed_db/cursor.rs | 14 +- crates/storage/src/indexed_db/index.rs | 4 +- crates/storage/src/indexed_db/mod.rs | 283 +----------------- crates/storage/src/indexed_db/object_store.rs | 20 +- crates/storage/src/indexed_db/request.rs | 227 ++++++++++++++ crates/storage/src/indexed_db/util.rs | 18 +- crates/storage/tests/indexed_db.rs | 56 ++-- 8 files changed, 536 insertions(+), 313 deletions(-) create mode 100644 crates/storage/src/indexed_db/request.rs diff --git a/crates/storage/README.md b/crates/storage/README.md index 8d327cff..bb469c7a 100644 --- a/crates/storage/README.md +++ b/crates/storage/README.md @@ -19,8 +19,231 @@ Built with 🦀🕸 by The Rust and WebAssembly Working Group -This crate provides wrappers for the -[Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API) +This crate provides wrappers for the [Web Storage API] and [IndexedDB API]. The data is stored in JSON form. We use [`serde`](https://serde.rs) for serialization and deserialization. + +# Indexed DB wrapper +This section explains how the IndexedDB wrapper works, and explains the design decisions taken, with +alternatives and rationale for the chosen option. + +## Intro +`IndexedDB` (a.k.a `IDB`) is an object-store type database defined by the World-wide Web Consortium +([specification][idb spec]). It replaces earlier `WebSQL`, and is preferred over the [Web Storage API] +when working with larger amounts of data, as it will not block the JavaScript thread when fetching or storing +data. Features include + + - [Named object stores][IDBObjectStore] + - [Indexes][IDBIndex] + - [Cursors][IDBCursor] which allow iteration over objects without having to load them all at once, objects + can be inspected and then modified/deleted without interrupting the cursor + +## Guide + +IndexedDB is a database that can be used in web browsers or other places JavaScript is used. When you change +or get data from the database, the operation doesn't finish instantly. Instead, you get an request back, and a +way to be notified when it has finished. The IDB wrapper in `gloo-storage` turns these requests into `Future`s +so you can use them the same way you'd use any other Rust future. + +TODO more content. I don't think I need to recreate the docs, a worked example would be more useful and +shorter. + +## Motivation + +The reason for writing a wrapper for IDB is that it is really quite hard to use, even if we were writing +JavaScript. It requires the user to understand the purpose and operation of [IDBRequest], which looks +quite like a Rust future, but where concepts are named differently and callbacks are required to respond to +events. We can wrap the API to provide much more ideomatic Rust APIs with no or very little overhead over +what would be required anyway. We can also close off entire classes of errors using the type system, which +in JavaScript require exceptions (JS is loosely typed so cannot do what we can). + +## Internal explanation +The internal workings of our wrapper. + +### [IDBRequest] +The `[IDBRequest]` interface is the core of the IDB API. It provides the mechanism for operations to take place +asynchronously. It is created immediately by many IDB operations, and will fire events when the operation +completes, whether successfully or otherwise. Usually it completes at most once, but when using cursors it can +complete many times, moving back and forth between pending and done. The main methods on the interface are: + + - `readyState`: contains the state of the operation, and is a string matching either `"pending"` or `"done"`. + Its value won't change within the same JS task, but can change between tasks. + - `result`: contains the result of the request (or this result if it's a sequence) if it was successful. + - `error`: contains the error if the request failed, or `NoError` if it succeeded. + - `transaction`: the transaction object that created this request. Not all requests are associated + with transactions. + - The `success` event: indicates the request has succeeded and its `result` is available. + - The `error` event: indicates the request has failed and the `error` is available. + +We will ignore the `source` property, because the user will always already have access to the source (since +they needed it to create the request), and we can in theory prevent some errors by preventing it from being +accessed here. + +The API above looks almost identical to a Rust `Future`. When we poll a request, we check its `readyState` to +see if we can complete straight away. If not, we make sure we are woken on completion by setting event handlers +for success and error. Putting this all together we get the implementation: + +```rust +struct Request { + // the raw request we wrap + inner: IdbRequest, + // by default, errors bubble up to cancel the transaction. We provide a flag to turn this off. + bubble_errors: bool, + // the event listeners + success_listener: Option, + error_listener: Option, +} + +impl Request { + fn new(inner: IdbRequest, bubble_errors: bool) -> Self { + Self { + inner, + bubble_errors, + success_listener: None, + error_listener: None, + } + } +} + +impl Future for Request { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.inner.ready_state() { + IdbRequestReadyState::Pending => { + if self.success_listener.is_none() { + self.success_listener = Some(EventListener::once(&self.inner, "success", { + let waker = cx.waker().clone(); + move |_| waker.wake() + })) + } + if self.error_listener.is_none() { + let opts = if self.bubble_errors { + EventListenerOptions::enable_prevent_default() + } else { + EventListenerOptions::default() + }; + self.error_listener = Some(EventListener::once_with_options( + &self.inner, + "error", + opts, + { + let waker = cx.waker().clone(); + let bubble_errors = self.bubble_errors; + move |event| { + waker.wake(); + if !bubble_errors { + event.prevent_default(); + } + } + }, + )) + } + Poll::Pending + } + IdbRequestReadyState::Done => { + if let Some(error) = self.inner.error().unreachable_throw() { + Poll::Ready(Err(error)) + } else { + // no error = success + Poll::Ready(Ok(self.inner.result().unreachable_throw())) + } + } + _ => unreachable_throw(), + } + } +} +``` + +I'm not going to post all of the implementation, but since this is the core of the whole wrapper, it's worth +reproducing verbatim. To put it simply, the operation of both the `Future` interface in Rust and the callback +interface of `IDBRequest` are the same: check if the operation is already done, then if not ask to be told +when it is. The only difference is the use of callbacks vs. task wakers, which this wrapper handles +transparently, so the user doesn't even need to be aware. + +There are two other variants of the `Request` data. One is `OpenDbRequest`, which works the same except for +handling an extra event `blocked`. This event tells the user if the database is already in use. Since this +often indicates a programmer mistake, we provide an option to turn this event into an error. + +> This wrapper doesn't provide any other way of handling the event currently. Open question: should it? + +The other is a wrapper for `IDBRequest`s that can change back from `done` to `pending`. This only happens +when using cursors, so we create two different wrappers to make the other cases simpler. The streaming +version implements `Stream`, which is Rust's abstraction for futures that return multiple times. + +### Transactions, Object stores, Indexes, and Cursors + +A lot of the contents of the IDB wrapper just forward straight to their `web_sys` analogs, so there really +isn't that much to say about them. One exception to this rule is how the different types of transactions +are handled. These are `"readonly"`, `"readwrite"`, and `"versionchange"`. The three types of transaction +determine what you can do with the database: you need to be in a *versionchange* transaction to alter the +structure of the database (the other two types are self-explanatory). In the IDB spec this information is +stored internally, but we have the opportunity to use Rust's type system to make this explicit. The advantage +of this approach is that we can catch invalid use of the API (for example updating a record in a readonly +transaction) at compile-time! This is currently implemented using "uninhabited enums": enums that have no +variants so can never be constructed. They are simply used as generic markers so that our other structs +know what operations they are allowed to do. + +> Alternatives are to not have different Rust types for different types of transaction. In this case we push +> the errors back to runtime, which seems like a regression to me. We could also have different types for each +> transaction, objecstore, ... (which would look like `ReadOnlyTransaction`, ReadWriteTransaction`, and so on). +> The downside to this is that we have more types and duplcated methods. The upside is that there are no +> generics, so it might be easier to understand for newcomers. + +### Options + +Where there is more than one argument to a function, this wrapper prefer using a special "Options" struct +(for example `ObjectStoreOptions`), with a `Default` impl so you can do e.g. `ObjectStoreOptions::default()` +if you don't want to change any of the defaults. They use the non-borrowing builder struct pattern. + +### Key and KeyPath + +The `Key` and `KeyPath` are new types introduced in this wrapper: the equivalent is untyped in JS. Strongly +typing this values enables us to catch more errors when they are created rather than used, which should aid +debugging. We can also provide more useful error messages. + +> The alternative here is either to make a trait for valid values, or just accept untyped `JsValue`s. I think +> both are worse. The trait alternative means learning another new trait, rather than just using `From` and +> `TryFrom`, while the untyped option makes the API much more error-prone (methods will throw for invalid +> `Key`s and `KeyPath`s. The downsides to the used design is that mistakes in implementing the validity +> tests will result in opaque error messages. We could possibly make these messages better, or better in debug +> builds if there was a perf cost. + +**TODO note**: currently the `KeyPath` uses the custom trait alternative design. I'm planning to change this +before marking the work ready for merge. + +### Query + +The concept of a "query" doesn't exist in IDB, but this wrapper introduces it to encapsulate filtering a +set of records. It can be 'all records', a single record, or a range of records. The JS equivalent involves +calling different methods depending on the type of query, and if its a range, the type of range as well. + +> There are a number of choices we could make here. We could do away with the `Query` and just expose more +> methods for the different cases, but I think this makes the API significantly more cumbersome and +> error-prone. The `Query` name is somewhat arbitrary, and we could bikeshed alternatives (maybe `Filter`?). +> The `(&Key, bool)` tuple could be a named struct if people think this would aid readability. And +> the error semantics are currently designed to match the underlying JS - we could make them more like +> Rust `Range` semantics. + +### Opening a database + +When opening a database, we have to provide the user with some way of specifying how the database should +be updated. This wrapper uses a callback, with the new and old versions provided, along with an object for +modifying the database. + +> There could conceivably be some declarative alternative, where you specify what the DB looks like in each +> version and library code works out exactly what to do to make it so. I think this is certainly not in +> gloo's remit as a 'middle-layer' and so should be discounted. + +### Error handling + +I think there is a potentially really good design for error handling, where most error types are the same, +but I haven't quite got it nailed yet. Will update once it's sorted. + +[Web Storage API]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API +[IndexedDB API]: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API +[idb spec]: https://w3c.github.io/IndexedDB/ +[IDBObjectStore]: https://developer.mozilla.org/en-US/docs/Web/API/IDBObjectStore +[IDBIndex]: https://developer.mozilla.org/en-US/docs/Web/API/IDBIndex +[IDBCursor]: https://developer.mozilla.org/en-US/docs/Web/API/IDBCursor diff --git a/crates/storage/src/indexed_db/cursor.rs b/crates/storage/src/indexed_db/cursor.rs index 2ca5db0f..da790840 100644 --- a/crates/storage/src/indexed_db/cursor.rs +++ b/crates/storage/src/indexed_db/cursor.rs @@ -1,4 +1,8 @@ -use super::{errors, util::UnreachableExt, Request, StreamingRequest}; +use super::{ + errors, + util::{unreachable_throw, UnreachableExt}, + Request, StreamingRequest, +}; use futures::stream::Stream; use serde::{Deserialize, Serialize}; use std::{ @@ -79,7 +83,7 @@ impl Stream for CursorStream { Poll::Ready(None) => Poll::Ready(None), Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(errors::LifetimeError::from(e)))), Poll::Ready(Some(Ok(next))) => { - let cursor = next.dyn_into::().unwrap_unreachable(); + let cursor = next.dyn_into::().unreachable_throw(); if self.state.take() { Poll::Ready(Some(Ok(Cursor::new(cursor, self.state.clone())))) } else { @@ -116,7 +120,7 @@ impl Cursor { /// Get the value at the current location of this cursor. pub fn value_raw(&self) -> JsValue { - self.raw().value().unwrap_unreachable() + self.raw().value().unreachable_throw() } /// The value of the object the cursor is currently pointing to. @@ -207,7 +211,7 @@ impl KeyCursor { /// Get the primary key for the current record. pub fn primary_key_raw(&self) -> JsValue { // Unwrap: the `Stream` implementation ensures that the cursor is valid and not moving - self.inner.primary_key().unwrap_unreachable() + self.inner.primary_key().unreachable_throw() } /// Get the primary key for the current record. @@ -304,7 +308,7 @@ impl From for CursorDirection { IdbCursorDirection::Nextunique => CursorDirection::NextUnique, IdbCursorDirection::Prev => CursorDirection::Prev, IdbCursorDirection::Prevunique => CursorDirection::PrevUnique, - _ => throw_str("unreachable"), + _ => unreachable_throw(), } } } diff --git a/crates/storage/src/indexed_db/index.rs b/crates/storage/src/indexed_db/index.rs index 0545d054..62a63df5 100644 --- a/crates/storage/src/indexed_db/index.rs +++ b/crates/storage/src/indexed_db/index.rs @@ -21,7 +21,7 @@ impl Index { /// Get the path to the key/keys for this index. pub fn key_path(&self) -> JsValue { - self.inner.key_path().unwrap_unreachable() + self.inner.key_path().unreachable_throw() } /// How an array key is handled. @@ -43,7 +43,7 @@ impl Index { let count = Request::new(request, false) .await .map_err(errors::LifetimeError::from)?; - let count = count.as_f64().unwrap_unreachable(); + let count = count.as_f64().unreachable_throw(); // assume count is a valid u32 Ok(count as u32) } diff --git a/crates/storage/src/indexed_db/mod.rs b/crates/storage/src/indexed_db/mod.rs index cd1f2c8c..13a1d70d 100644 --- a/crates/storage/src/indexed_db/mod.rs +++ b/crates/storage/src/indexed_db/mod.rs @@ -1,25 +1,17 @@ //! A futures-based wrapper around indexed DB. -use futures::stream::Stream; -use gloo_events::{EventListener, EventListenerOptions}; +use gloo_events::EventListener; use gloo_utils::window; -use std::{ - future::Future, - ops::Deref, - pin::Pin, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - task::{Context, Poll}, -}; -use wasm_bindgen::{prelude::*, throw_str, JsCast, UnwrapThrowExt}; +use std::{future::Future, ops::Deref}; +use wasm_bindgen::{prelude::*, JsCast, UnwrapThrowExt}; use web_sys::{ - DomException, IdbDatabase, IdbFactory, IdbObjectStoreParameters, IdbOpenDbRequest, IdbRequest, - IdbRequestReadyState, IdbTransactionMode, IdbVersionChangeEvent, + IdbDatabase, IdbFactory, IdbObjectStoreParameters, IdbTransactionMode, IdbVersionChangeEvent, }; mod util; +use util::UnreachableExt; pub use util::{StringList, StringListIter}; +mod request; +use request::{OpenDbRequest, Request, StreamingRequest}; mod object_store; pub use object_store::{CursorOptions, IndexOptions, ObjectStore}; mod key; @@ -106,27 +98,26 @@ impl Database { let request = indexed_db() .ok_or(errors::OpenDatabaseError::IndexedDbUnsupported)? .open_with_u32(name, version) - .expect_throw("Database::open"); + .unreachable_throw(); // Listeners keep the closures alive unless dropped, in which case they are cleaned up. // Using `let _ = ...` would immediately drop the closure meaning it is not run. let _upgrade_listener = EventListener::new(&request, "upgradeneeded", { let request = request.clone(); move |event| { - let event = event - .dyn_ref::() - .expect_throw("IdbVersionChangeEvent dyn_into"); + let event = event.unchecked_ref::(); let old_version = event.old_version() as u32; // newVersion is only null in a delete transation (which we know isn't happening // here) let new_version = event.new_version().unwrap_throw() as u32; let db = request .result() - .expect_throw("IdbOpenDatabaseRequest::result") - .dyn_into::() - .expect_throw("IdbDatabase dyn_into"); + .unreachable_throw() + .unchecked_into::(); - // This seems to be the way to get a transation + // Usually the transaction will be used to create a request, but in the db upgrade + // case this is the only way to gain access to the upgrade transaction. We grab it + // here to allow the user to do open existing object stores etc during upgrade. let transaction = request.transaction().expect_throw("request.transaction()"); let transaction: Transaction = Transaction::new(transaction); @@ -142,9 +133,7 @@ impl Database { let result = OpenDbRequest::new(request, error_on_block) .await .map_err(errors::OpenDatabaseError::from)?; - let inner = result - .dyn_into::() - .expect_throw("dyn_into IdbDatabase"); + let inner = result.dyn_into::().unreachable_throw(); Ok(Database { inner }) } @@ -326,245 +315,3 @@ impl Default for ObjectStoreOptions { Self::new() } } - -/// Wrapper around IdbRequest that implements `Future`. -/// -/// We don't need to expose this - we just return it as an `impl Future<_>`. -struct Request { - inner: IdbRequest, - bubble_errors: bool, - success_listener: Option, - error_listener: Option, -} - -impl Request { - fn new(inner: IdbRequest, bubble_errors: bool) -> Self { - Self { - inner, - bubble_errors, - success_listener: None, - error_listener: None, - } - } -} - -impl Future for Request { - type Output = Result; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match self.inner.ready_state() { - IdbRequestReadyState::Pending => { - if self.success_listener.is_none() { - self.success_listener = Some(EventListener::once(&self.inner, "success", { - let waker = cx.waker().clone(); - move |_| waker.wake() - })) - } else { - throw_str("success_listener") - } - if self.error_listener.is_none() { - let opts = if self.bubble_errors { - EventListenerOptions::enable_prevent_default() - } else { - EventListenerOptions::default() - }; - self.error_listener = Some(EventListener::once_with_options( - &self.inner, - "error", - opts, - { - let waker = cx.waker().clone(); - let bubble_errors = self.bubble_errors; - move |event| { - waker.wake(); - if !bubble_errors { - event.prevent_default(); - } - } - }, - )) - } else { - throw_str("error_listener") - } - Poll::Pending - } - IdbRequestReadyState::Done => { - if let Some(error) = self.inner.error().expect_throw("get error") { - Poll::Ready(Err(error)) - } else { - // no error = success - Poll::Ready(Ok(self.inner.result().expect_throw("get result"))) - } - } - _ => throw_str("unknown ReadyState"), - } - } -} - -/// Wrapper around IdbRequest that implements `Future`. -struct OpenDbRequest { - inner: IdbOpenDbRequest, - error_on_block: bool, - success_listener: Option, - error_listener: Option, - blocked_listener: Option, - blocked: Arc, -} - -impl OpenDbRequest { - fn new(inner: IdbOpenDbRequest, error_on_block: bool) -> Self { - Self { - inner, - error_on_block, - success_listener: None, - error_listener: None, - blocked_listener: None, - blocked: Arc::new(AtomicBool::new(false)), - } - } -} - -impl Future for OpenDbRequest { - type Output = Result; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - if self.blocked.load(Ordering::SeqCst) { - // return error - return Poll::Ready(Err(DomException::new_with_message_and_name( - "transaction would block", - "TransactionWouldBlock", - ) - .expect_throw("DomException"))); - } - - match self.inner.ready_state() { - IdbRequestReadyState::Pending => { - if self.success_listener.is_none() { - self.success_listener = Some(EventListener::once(&self.inner, "success", { - let waker = cx.waker().clone(); - move |_| waker.wake() - })) - } else { - throw_str("success_listener") - } - if self.error_listener.is_none() { - self.error_listener = Some(EventListener::once(&self.inner, "error", { - let waker = cx.waker().clone(); - move |_| waker.wake() - })) - } else { - throw_str("error_listener") - } - if self.error_on_block { - if self.blocked_listener.is_none() { - self.blocked_listener = Some(EventListener::once(&self.inner, "blocked", { - let blocked = self.blocked.clone(); - let waker = cx.waker().clone(); - move |_| { - blocked.store(true, Ordering::SeqCst); - waker.wake(); - } - })) - } else { - throw_str("blocked_lsitener") - } - } - Poll::Pending - } - IdbRequestReadyState::Done => { - if let Some(error) = self.inner.error().expect_throw("error") { - Poll::Ready(Err(error)) - } else { - // no error = success - Poll::Ready(Ok(self.inner.result().expect_throw("result"))) - } - } - _ => throw_str("ready state"), - } - } -} - -/// Wrapper for IDBRequest where the success callback is run multiple times. -// TODO If a task is woken up, does `wasm_bindgen_futures` try to progress the future in the same -// microtask or a separate one? This will impact whether I need to have space for more than one -// result at a time. -#[derive(Debug)] -pub struct StreamingRequest { - inner: IdbRequest, - bubble_errors: bool, - success_listener: Option, - error_listener: Option, -} - -impl StreamingRequest { - fn new(inner: IdbRequest, bubble_errors: bool) -> Self { - Self { - inner, - bubble_errors, - success_listener: None, - error_listener: None, - } - } -} - -impl Stream for StreamingRequest { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.inner.ready_state() { - IdbRequestReadyState::Pending => { - if self.success_listener.is_none() { - // First call - setup - self.success_listener = Some(EventListener::new(&self.inner, "success", { - let waker = cx.waker().clone(); - move |_| { - let waker = waker.clone(); - waker.wake() - } - })); - - // omit the error.is_none check to save a branch. - let opts = if self.bubble_errors { - EventListenerOptions::enable_prevent_default() - } else { - EventListenerOptions::default() - }; - self.error_listener = Some(EventListener::new_with_options( - &self.inner, - "error", - opts, - { - let waker = cx.waker().clone(); - let bubble_errors = self.bubble_errors; - move |event| { - let waker = waker.clone(); - waker.wake(); - if !bubble_errors { - event.prevent_default(); - } - } - }, - )); - } - - Poll::Pending - } - IdbRequestReadyState::Done => { - if let Some(error) = self.inner.error().expect_throw("get error") { - Poll::Ready(Some(Err(error))) - } else { - // no error = success - // if the result is null, there won't be any more entries (at least for - // IDBCursor, which I think is the only case a request is re-used) - let result = self.inner.result().expect_throw("get result"); - if result.is_null() || result.is_undefined() { - Poll::Ready(None) - } else { - Poll::Ready(Some(Ok(result))) - } - } - } - _ => throw_str("unreachable"), - } - } -} diff --git a/crates/storage/src/indexed_db/object_store.rs b/crates/storage/src/indexed_db/object_store.rs index 29b365a0..e174da87 100644 --- a/crates/storage/src/indexed_db/object_store.rs +++ b/crates/storage/src/indexed_db/object_store.rs @@ -1,12 +1,14 @@ // NOTE: all transaction operations must be started on the *same tick* (i.e. not in an async block) // otherwise with transaction will auto-commit before the operation is started. use super::{ - errors, util::UnreachableExt, CursorDirection, CursorStream, Index, IntoKeyPath, Key, KeyPath, - Query, ReadOnly, ReadWrite, Request, StreamingRequest, StringList, Upgrade, + errors, + util::{unreachable_throw, UnreachableExt}, + CursorDirection, CursorStream, Index, IntoKeyPath, Key, KeyPath, Query, ReadOnly, ReadWrite, + Request, StreamingRequest, StringList, Upgrade, }; use serde::{Deserialize, Serialize}; use std::{future::Future, marker::PhantomData}; -use wasm_bindgen::{prelude::*, throw_str, JsCast}; +use wasm_bindgen::{prelude::*, JsCast}; use web_sys::{IdbIndexParameters, IdbObjectStore}; /// An indexedDB object store. @@ -109,7 +111,7 @@ impl ObjectStore { bubble_errors: bool, ) -> Result<(), errors::AddError> { // TODO handle errors - let value = serde_wasm_bindgen::to_value(value).unwrap_unreachable(); + let value = serde_wasm_bindgen::to_value(value).unreachable_throw(); self.add_raw_inner(&value, key, bubble_errors).await } @@ -142,7 +144,7 @@ impl ObjectStore { bubble_errors: bool, ) -> Result<(), errors::AddError> { // TODO handle errors - let value = serde_wasm_bindgen::to_value(value).unwrap_unreachable(); + let value = serde_wasm_bindgen::to_value(value).unreachable_throw(); self.put_raw_inner(&value, key, bubble_errors).await } @@ -216,7 +218,7 @@ impl ObjectStore { // Note: return value should be either null, a DOMString, or a sequence (from w3 // spec) pub fn key_path_inner(&self) -> KeyPath { - let key_path = self.inner.key_path().unwrap_unreachable(); + let key_path = self.inner.key_path().unreachable_throw(); if key_path.is_null() { KeyPath::None } else if let Some(key_path) = key_path.as_string() { @@ -226,7 +228,7 @@ impl ObjectStore { let mut out = vec![]; for val in &key_path { - out.push(val.unwrap_unreachable().as_string().unwrap_unreachable()); + out.push(val.unreachable_throw().as_string().unreachable_throw()); } KeyPath::Sequence(out) } @@ -249,12 +251,12 @@ impl ObjectStore { ) .await .map_err(errors::CountError::from)?; - let result = result.as_f64().expect_throw("unreachable"); + let result = result.as_f64().unreachable_throw(); // From reading MDN it seems indexeddb cannot handle counts more than 2^32-1. if result <= u32::MAX.into() { Ok(result as u32) } else { - throw_str("unreachable") + unreachable_throw() } } diff --git a/crates/storage/src/indexed_db/request.rs b/crates/storage/src/indexed_db/request.rs new file mode 100644 index 00000000..db1db549 --- /dev/null +++ b/crates/storage/src/indexed_db/request.rs @@ -0,0 +1,227 @@ +//! Contains wrappers for `IDBRequest`. These are only exposed to the user as either a `Future` or +//! a `Stream`. +use futures::stream::Stream; +use gloo_events::{EventListener, EventListenerOptions}; +use std::{ + future::Future, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; +use wasm_bindgen::prelude::*; +use web_sys::{DomException, IdbOpenDbRequest, IdbRequest, IdbRequestReadyState}; + +use crate::indexed_db::util::{unreachable_throw, UnreachableExt}; + +/// Wrapper around IdbRequest that implements `Future`. +pub(crate) struct Request { + inner: IdbRequest, + bubble_errors: bool, + success_listener: Option, + error_listener: Option, +} + +impl Request { + pub(crate) fn new(inner: IdbRequest, bubble_errors: bool) -> Self { + Self { + inner, + bubble_errors, + success_listener: None, + error_listener: None, + } + } +} + +impl Future for Request { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.inner.ready_state() { + IdbRequestReadyState::Pending => { + if self.success_listener.is_none() { + self.success_listener = Some(EventListener::once(&self.inner, "success", { + let waker = cx.waker().clone(); + move |_| waker.wake() + })) + } + if self.error_listener.is_none() { + let opts = if self.bubble_errors { + EventListenerOptions::enable_prevent_default() + } else { + EventListenerOptions::default() + }; + self.error_listener = Some(EventListener::once_with_options( + &self.inner, + "error", + opts, + { + let waker = cx.waker().clone(); + let bubble_errors = self.bubble_errors; + move |event| { + waker.wake(); + if !bubble_errors { + event.prevent_default(); + } + } + }, + )) + } + Poll::Pending + } + IdbRequestReadyState::Done => { + if let Some(error) = self.inner.error().unreachable_throw() { + Poll::Ready(Err(error)) + } else { + // no error = success + Poll::Ready(Ok(self.inner.result().unreachable_throw())) + } + } + _ => unreachable_throw(), + } + } +} + +/// Special `IDBRequest` wrapper that optionally handles the `blocked` event, returning an error if +/// the request would block on another user operation. +/// +/// Users can set the error on block flag if concurrent use of the database indicates an error. +pub(crate) struct OpenDbRequest { + inner: Request, + error_on_block: bool, + blocked_listener: Option, + blocked: Arc, +} + +impl OpenDbRequest { + pub(crate) fn new(inner: IdbOpenDbRequest, error_on_block: bool) -> Self { + Self { + inner: Request::new(inner.into(), true), + error_on_block, + blocked_listener: None, + blocked: Arc::new(AtomicBool::new(false)), + } + } +} + +impl Future for OpenDbRequest { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.blocked.load(Ordering::SeqCst) { + // return error + return Poll::Ready(Err(DomException::new_with_message_and_name( + "transaction would block", + "TransactionWouldBlock", + ) + .unreachable_throw())); + } + + match Pin::new(&mut self.inner).poll(cx) { + Poll::Pending => { + if self.error_on_block { + if self.blocked_listener.is_none() { + self.blocked_listener = + Some(EventListener::once(&self.inner.inner, "blocked", { + let blocked = self.blocked.clone(); + let waker = cx.waker().clone(); + move |_| { + blocked.store(true, Ordering::SeqCst); + waker.wake(); + } + })) + } + } + Poll::Pending + } + ready => ready, + } + } +} + +/// Wrapper for IDBRequest where the success callback is run multiple times. +// TODO If a task is woken up, does `wasm_bindgen_futures` try to progress the future in the same +// microtask or a separate one? This will impact whether I need to have space for more than one +// result at a time. +#[derive(Debug)] +pub(crate) struct StreamingRequest { + inner: IdbRequest, + bubble_errors: bool, + success_listener: Option, + error_listener: Option, +} + +impl StreamingRequest { + pub(crate) fn new(inner: IdbRequest, bubble_errors: bool) -> Self { + Self { + inner, + bubble_errors, + success_listener: None, + error_listener: None, + } + } +} + +impl Stream for StreamingRequest { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.inner.ready_state() { + IdbRequestReadyState::Pending => { + if self.success_listener.is_none() { + // First call - setup + self.success_listener = Some(EventListener::new(&self.inner, "success", { + let waker = cx.waker().clone(); + move |_| { + let waker = waker.clone(); + waker.wake() + } + })); + + // omit the error.is_none check to save a branch. + let opts = if self.bubble_errors { + EventListenerOptions::enable_prevent_default() + } else { + EventListenerOptions::default() + }; + self.error_listener = Some(EventListener::new_with_options( + &self.inner, + "error", + opts, + { + let waker = cx.waker().clone(); + let bubble_errors = self.bubble_errors; + move |event| { + let waker = waker.clone(); + waker.wake(); + if !bubble_errors { + event.prevent_default(); + } + } + }, + )); + } + + Poll::Pending + } + IdbRequestReadyState::Done => { + if let Some(error) = self.inner.error().unreachable_throw() { + Poll::Ready(Some(Err(error))) + } else { + // no error = success + // if the result is null, there won't be any more entries (at least for + // IDBCursor, which I think is the only case a request is re-used) + let result = self.inner.result().unreachable_throw(); + if result.is_null() || result.is_undefined() { + Poll::Ready(None) + } else { + Poll::Ready(Some(Ok(result))) + } + } + } + _ => unreachable_throw(), + } + } +} diff --git a/crates/storage/src/indexed_db/util.rs b/crates/storage/src/indexed_db/util.rs index 4877b56e..bbc02e51 100644 --- a/crates/storage/src/indexed_db/util.rs +++ b/crates/storage/src/indexed_db/util.rs @@ -1,16 +1,26 @@ -use wasm_bindgen::{intern, UnwrapThrowExt}; +use once_cell::sync::Lazy; +use wasm_bindgen::{intern, throw_str, UnwrapThrowExt}; use web_sys::DomStringList; +// TODO need to work out what the most efficient way to do this is. Hopefully interning once is the +// best, but it might not make any difference (over interning every time). I don't know the +// internals of `intern`. +static UNREACHABLE_MSG: Lazy<&'static str> = Lazy::new(|| intern("internal error: unreachable")); + pub(crate) trait UnreachableExt: UnwrapThrowExt { - fn unwrap_unreachable(self) -> T; + fn unreachable_throw(self) -> T; } impl> UnreachableExt for R { - fn unwrap_unreachable(self) -> T { - self.expect_throw(intern("unreachable")) + fn unreachable_throw(self) -> T { + self.expect_throw(&UNREACHABLE_MSG) } } +pub(crate) fn unreachable_throw() -> ! { + throw_str(&UNREACHABLE_MSG) +} + /// A wrapper around [`web_sys::DomStringList`] for easy iteration. #[derive(Debug)] pub struct StringList { diff --git a/crates/storage/tests/indexed_db.rs b/crates/storage/tests/indexed_db.rs index b0c09d64..a2634331 100644 --- a/crates/storage/tests/indexed_db.rs +++ b/crates/storage/tests/indexed_db.rs @@ -1,33 +1,39 @@ -use gloo_storage::indexed_db::{delete_database, Database, DatabaseDuringUpgrade}; +use gloo_storage::indexed_db as idb; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen_test] async fn create_db() { - delete_database("create_db", true).await.unwrap(); - Database::open("create_db", 1, |_| (), true).await.unwrap(); + idb::delete_database("create_db", true) + .await + .expect("deleting db"); + idb::Database::open("create_db", 1, |_| (), true) + .await + .expect("opening db"); } #[wasm_bindgen_test] async fn create_delete_object_store() { - fn db_upgrade(db: DatabaseDuringUpgrade) { + fn db_upgrade(db: idb::DatabaseDuringUpgrade) { if db.old_version() < 1 && db.new_version() >= 1 { - db.create_object_store("name") - .auto_increment(true) - .key_path("id") - .build() - .unwrap(); + db.create_object_store( + "name", + idb::ObjectStoreOptions::default() + .auto_increment(true) + .key_path("id"), + ) + .unwrap(); } if db.old_version() < 2 && db.new_version() >= 2 { db.delete_object_store("name").unwrap(); } } - delete_database("create_delete_object_store", false) + idb::delete_database("create_delete_object_store", false) .await .unwrap(); - let db = Database::open("create_delete_object_store", 1, db_upgrade, false) + let db = idb::Database::open("create_delete_object_store", 1, db_upgrade, false) .await .unwrap(); assert_eq!( @@ -35,7 +41,7 @@ async fn create_delete_object_store() { vec!["name"] ); drop(db); - let db = Database::open("create_delete_object_store", 2, db_upgrade, false) + let db = idb::Database::open("create_delete_object_store", 2, db_upgrade, false) .await .unwrap(); assert!(db.object_store_names().is_empty()); @@ -43,27 +49,31 @@ async fn create_delete_object_store() { #[wasm_bindgen_test] async fn get_upgrade_transaction() { - fn db_upgrade(db: DatabaseDuringUpgrade) { + fn db_upgrade(db: idb::DatabaseDuringUpgrade) { if db.old_version() < 1 && db.new_version() >= 1 { - db.create_object_store("name") - .auto_increment(true) - .key_path("id") - .build() - .unwrap(); + db.create_object_store( + "name", + idb::ObjectStoreOptions::default() + .auto_increment(true) + .key_path("id"), + ) + .unwrap(); } if db.old_version() < 2 && db.new_version() >= 2 { - let store = db.upgrade_transaction().object_store("name").unwrap(); - store.create_index("name", "name"); + let store = db.transaction().object_store("name").unwrap(); + store + .create_index("name", idb::IndexOptions::default().key_path("name")) + .unwrap(); } } - delete_database("get_upgrade_transaction", false) + idb::delete_database("get_upgrade_transaction", false) .await .unwrap(); - let db = Database::open("get_upgrade_transaction", 1, db_upgrade, false) + let db = idb::Database::open("get_upgrade_transaction", 1, db_upgrade, false) .await .unwrap(); drop(db); - let _db = Database::open("get_upgrade_transaction", 2, db_upgrade, false) + let _db = idb::Database::open("get_upgrade_transaction", 2, db_upgrade, false) .await .unwrap(); } From 5f288b1c9af535ad382abb052a1c28990bdb58de Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Mon, 2 May 2022 15:54:32 +0100 Subject: [PATCH 8/8] Make clippy happy --- crates/storage/src/indexed_db/request.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/storage/src/indexed_db/request.rs b/crates/storage/src/indexed_db/request.rs index db1db549..b73ac9d2 100644 --- a/crates/storage/src/indexed_db/request.rs +++ b/crates/storage/src/indexed_db/request.rs @@ -121,18 +121,16 @@ impl Future for OpenDbRequest { match Pin::new(&mut self.inner).poll(cx) { Poll::Pending => { - if self.error_on_block { - if self.blocked_listener.is_none() { - self.blocked_listener = - Some(EventListener::once(&self.inner.inner, "blocked", { - let blocked = self.blocked.clone(); - let waker = cx.waker().clone(); - move |_| { - blocked.store(true, Ordering::SeqCst); - waker.wake(); - } - })) - } + if self.error_on_block && self.blocked_listener.is_none() { + self.blocked_listener = + Some(EventListener::once(&self.inner.inner, "blocked", { + let blocked = self.blocked.clone(); + let waker = cx.waker().clone(); + move |_| { + blocked.store(true, Ordering::SeqCst); + waker.wake(); + } + })) } Poll::Pending }