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..eedde1ec 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -12,16 +12,45 @@ 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" +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" features = [ - "Storage", "Window", + "Navigator", + "StorageManager", + "Storage", + "console", + # indexeddb + "DomException", + "DomStringList", + "IdbCursor", + "IdbCursorWithValue", + "IdbCursorDirection", + "IdbDatabase", + "IdbFactory", + "IdbIndex", + "IdbIndexParameters", + "IdbKeyRange", + "IdbObjectStore", + "IdbObjectStoreParameters", + "IdbOpenDbRequest", + "IdbRequest", + "IdbRequestReadyState", + "IdbTransaction", + "IdbTransactionMode", + "IdbVersionChangeEvent", ] [dev-dependencies] 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 new file mode 100644 index 00000000..da790840 --- /dev/null +++ b/crates/storage/src/indexed_db/cursor.rs @@ -0,0 +1,314 @@ +use super::{ + errors, + util::{unreachable_throw, UnreachableExt}, + Request, StreamingRequest, +}; +use futures::stream::Stream; +use serde::{Deserialize, Serialize}; +use std::{ + cell::Cell, + marker::PhantomData, + ops::Deref, + pin::Pin, + sync::{ + atomic::{AtomicU8, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; +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 + .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + true + } else { + 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 { + /// Every time the request succeeds, its result is an instance of cursor. + request: StreamingRequest, + ty: PhantomData, + state: StreamState, +} + +impl CursorStream { + pub(crate) fn new(request: StreamingRequest) -> Self { + Self { + request, + ty: PhantomData, + state: StreamState::new(), + } + } +} + +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::LifetimeError::from(e)))), + Poll::Ready(Some(Ok(next))) => { + let cursor = next.dyn_into::().unreachable_throw(); + 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(), + )))) + } + } + } + } +} + +/// 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, state: StreamState) -> Self { + Self { + inner: KeyCursor::new(inner.into(), state), + } + } + + 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().unreachable_throw() + } + + /// 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()) + } + + /// 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; + + 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, + /// Ensure that only one cursor object is held at any one time. + state: StreamState, + ty: PhantomData, +} + +impl KeyCursor { + fn new(inner: IdbCursor, state: StreamState) -> Self { + Self { + inner, + advanced: Cell::new(false), + state, + ty: PhantomData, + } + } + + /// 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().unreachable_throw() + } + + /// 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. + /// + /// # 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(()) + } + + /// Move the cursor on to the next record. + /// + /// Equivalent to `cursor.advance(1)` + 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 +/// 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, + _ => unreachable_throw(), + } + } +} diff --git a/crates/storage/src/indexed_db/errors.rs b/crates/storage/src/indexed_db/errors.rs new file mode 100644 index 00000000..5ba26557 --- /dev/null +++ b/crates/storage/src/indexed_db/errors.rs @@ -0,0 +1,343 @@ +//! 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 opening an index +#[derive(Debug, 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!(IndexError { + "InvalidStateError" => InvalidState, +}); + +/// 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 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, + /// This error occurs when the object we are running a query against has been deleted. + /// + /// 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!(LifetimeError { + "TransactionInactiveError" => TransactionInactive, + "InvalidStateError" => InvalidState, +}); + +/// A wrapper around other errors to include a de/serialization error variant. +#[derive(Debug, Error)] +pub enum DeSerialize { + /// A ser/de error + DeSerialize(#[from] serde_wasm_bindgen::Error), + /// A non-ser/de error + Other(E), +} + +// 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..62a63df5 --- /dev/null +++ b/crates/storage/src/indexed_db/index.rs @@ -0,0 +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 { + inner: IdbIndex, + ty: PhantomData, +} + +impl Index { + pub(crate) fn new(inner: IdbIndex) -> Self { + Self { + inner, + ty: PhantomData, + } + } + + /// Get the path to the key/keys for this index. + pub fn key_path(&self) -> JsValue { + self.inner.key_path().unreachable_throw() + } + + /// 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().unreachable_throw(); + // 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 new file mode 100644 index 00000000..58b16fd1 --- /dev/null +++ b/crates/storage/src/indexed_db/key.rs @@ -0,0 +1,312 @@ +use super::{errors, indexed_db}; +use std::{ + cmp::Ordering, + convert::TryFrom, + iter::FromIterator, + ops::{Deref, 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 itm in self { + arr.push(&JsValue::from(itm.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(JsValue::from) + .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 Deref for Key { + type Target = JsValue; + + fn deref(&self) -> &JsValue { + &self.0 + } +} + +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()) + } +} + +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 { + inner: JsValue, +} + +// TODO error type +impl Query { + /// A special range that includes all records in a store/index. + pub const ALL: Self = Self { + inner: JsValue::UNDEFINED, + }; + + /// 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(|_| "invalid range".into()) + } + + fn new(inner: IdbKeyRange) -> Self { + Self { + inner: inner.into(), + } + } +} + +impl TryFrom> for Query { + 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 = Box; + 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::from_range(Some((&range.start, false)), None).expect_throw("unreachable") + } +} + +impl From> for Query { + fn from(range: RangeTo) -> Self { + Self::from_range(None, Some((&range.end, true))).expect_throw("unreachable") + } +} + +impl From> for Query { + fn from(range: RangeToInclusive) -> Self { + Self::from_range(None, Some((&range.end, false))).expect_throw("unreachable") + } +} + +impl From<&Key> for Query { + fn from(key: &Key) -> Self { + Self::only(key) + } +} + +impl From for Query { + fn from(_: RangeFull) -> Self { + 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 new file mode 100644 index 00000000..13a1d70d --- /dev/null +++ b/crates/storage/src/indexed_db/mod.rs @@ -0,0 +1,317 @@ +//! A futures-based wrapper around indexed DB. +use gloo_events::EventListener; +use gloo_utils::window; +use std::{future::Future, ops::Deref}; +use wasm_bindgen::{prelude::*, JsCast, UnwrapThrowExt}; +use web_sys::{ + 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; +pub use key::{IntoKeyPath, Key, KeyPath, Query}; +mod transaction; +pub use transaction::Transaction; +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) + .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.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() + .unreachable_throw() + .unchecked_into::(); + + // 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); + + 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::().unreachable_throw(); + 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, 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, errors::StartTransactionError> { + self.transaction_inner(stores, IdbTransactionMode::Readonly) + } + + fn transaction_inner( + &self, + stores: &[impl AsRef], + mode: IdbTransactionMode, + ) -> 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(&array, mode) + .map_err(errors::StartTransactionError::from) + .map(Transaction::new) + } +} + +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 Transaction, +} + +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 + /// + /// # 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 + /// db.create_object_store("test") + /// .auto_increment(false) + /// .key_path("key.path") + /// .build() + /// ``` + 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 + /// + /// 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 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 transaction(&self) -> &'trans Transaction { + self.transaction + } +} + +impl<'trans> Deref for DatabaseDuringUpgrade<'trans> { + type Target = Database; + + fn deref(&self) -> &Database { + &self.db + } +} + +/// Possible objects when creating an object store +#[derive(Debug)] +pub struct ObjectStoreOptions { + inner: IdbObjectStoreParameters, +} + +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.inner.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.inner.key_path(Some(&key_path.into_jsvalue())); + self + } +} + +impl Default for ObjectStoreOptions { + fn default() -> Self { + Self::new() + } +} 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..e174da87 --- /dev/null +++ b/crates/storage/src/indexed_db/object_store.rs @@ -0,0 +1,566 @@ +// 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::{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::*, JsCast}; +use web_sys::{IdbIndexParameters, IdbObjectStore}; + +/// An indexedDB object store. +/// +/// The type `Ty` denotes what context the store exists (`Upgrade`, `ReadWrite`, or `ReadOnly`). +#[derive(Debug)] +pub struct ObjectStore { + inner: IdbObjectStore, + ty: PhantomData, +} + +impl ObjectStore { + /// Contract: the caller is responsible for choosing the right subtype + pub(crate) fn new(inner: IdbObjectStore) -> Self { + Self { + 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 + /// + /// 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. + 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. + 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. + fn delete_index_inner(&self, name: &str) -> Result<(), errors::DeleteIndexError> { + self.raw() + .delete_index(name) + .map_err(errors::DeleteIndexError::from) + } + + // 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. + async fn add_raw_inner( + &self, + value: &JsValue, + key: Option, + bubble_errors: bool, + ) -> Result<(), errors::AddError> { + let request = if let Some(key) = key { + self.raw().add_with_key(value, &key) + } else { + self.raw().add(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)), + } + } + + /// Add an arbitrary object to the database using serde to serialize it to a JsValue. + 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).unreachable_throw(); + 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, + ) -> Result<(), errors::AddError> { + // TODO handle errors + let value = serde_wasm_bindgen::to_value(value).unreachable_throw(); + self.put_raw_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. + fn clear_inner( + &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 + fn delete_inner( + &self, + key: Key, + bubble_errors: bool, + ) -> 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) + } + } + + /// 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, + ))) + } + + // Always valid (read only) + + /// 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.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_inner(&self) -> KeyPath { + 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() { + KeyPath::String(key_path) + } else { + let key_path = key_path.unchecked_into::(); + + let mut out = vec![]; + for val in &key_path { + out.push(val.unreachable_throw().as_string().unreachable_throw()); + } + KeyPath::Sequence(out) + } + } + + /// The name of the object store. + pub fn name(&self) -> String { + self.inner.name() + } + + /// Count the number of records in the object store. + /// + /// 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().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 { + unreachable_throw() + } + } + + /// Get an object from the object store by searching for the given key. + 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. + /// + /// Automatically deserializes the result. + pub async fn get( + &self, + key: Key, + ) -> Result, errors::DeSerialize> + where + V: for<'de> Deserialize<'de>, + { + 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?) + } + + /// Get a sequence of values + /// + /// The user should choose a collection type `C` that can deserialize a sequence of values (for + /// example `Vec` from the standard library). + /// + /// 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( + &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. + /// + /// 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. + /// + /// # 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.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 Default for IndexOptions { + fn default() -> Self { + Self::new() + } +} + +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. +} + +/// Options when opening a cursor. +#[derive(Debug)] +pub struct CursorOptions { + query: Query, + direction: CursorDirection, + bubble_errors: bool, +} + +impl CursorOptions { + fn new() -> Self { + Self { + 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 + } + + /// 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/request.rs b/crates/storage/src/indexed_db/request.rs new file mode 100644 index 00000000..b73ac9d2 --- /dev/null +++ b/crates/storage/src/indexed_db/request.rs @@ -0,0 +1,225 @@ +//! 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 && 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/transaction.rs b/crates/storage/src/indexed_db/transaction.rs new file mode 100644 index 00000000..7d7ebe16 --- /dev/null +++ b/crates/storage/src/indexed_db/transaction.rs @@ -0,0 +1,70 @@ +use super::{errors, ObjectStore}; +use js_sys::Reflect; +use once_cell::sync::Lazy; +use std::marker::PhantomData; +use wasm_bindgen::{prelude::*, JsCast}; +use web_sys::IdbTransaction; + +/// A database transaction. +/// +/// All interaction with a database happens in a transaction. +#[derive(Debug)] +pub struct Transaction { + inner: IdbTransaction, + ty: PhantomData, +} + +impl Transaction { + pub(crate) fn new(inner: IdbTransaction) -> Self { + Self { + inner, + ty: PhantomData, + } + } + + /// 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. + /// + /// 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(); + } + } +} + +// 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..bbc02e51 --- /dev/null +++ b/crates/storage/src/indexed_db/util.rs @@ -0,0 +1,98 @@ +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 unreachable_throw(self) -> T; +} + +impl> UnreachableExt for R { + 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 { + 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 f5713f67..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) //! @@ -8,12 +9,18 @@ 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 js_sys::Reflect; use serde_json::{Map, Value}; +#[macro_use] +mod macros; pub mod errors; +pub mod indexed_db; mod local_storage; mod session_storage; pub use local_storage::LocalStorage; @@ -95,3 +102,56 @@ pub trait Storage { .expect_throw("unreachable: length does not throw an exception") } } + +/// 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. +/// +/// 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() +} + +/// 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/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 new file mode 100644 index 00000000..a2634331 --- /dev/null +++ b/crates/storage/tests/indexed_db.rs @@ -0,0 +1,82 @@ +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() { + 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: idb::DatabaseDuringUpgrade) { + if db.old_version() < 1 && db.new_version() >= 1 { + 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(); + } + } + + idb::delete_database("create_delete_object_store", false) + .await + .unwrap(); + let db = idb::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 = idb::Database::open("create_delete_object_store", 2, db_upgrade, false) + .await + .unwrap(); + assert!(db.object_store_names().is_empty()); +} + +#[wasm_bindgen_test] +async fn get_upgrade_transaction() { + fn db_upgrade(db: idb::DatabaseDuringUpgrade) { + if db.old_version() < 1 && db.new_version() >= 1 { + 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.transaction().object_store("name").unwrap(); + store + .create_index("name", idb::IndexOptions::default().key_path("name")) + .unwrap(); + } + } + idb::delete_database("get_upgrade_transaction", false) + .await + .unwrap(); + let db = idb::Database::open("get_upgrade_transaction", 1, db_upgrade, false) + .await + .unwrap(); + drop(db); + let _db = idb::Database::open("get_upgrade_transaction", 2, db_upgrade, false) + .await + .unwrap(); +} + +#[wasm_bindgen_test] +async fn object_store_methods() {} diff --git a/examples/todomvc/.gitignore b/examples/todomvc/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/examples/todomvc/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/examples/todomvc/Cargo.toml b/examples/todomvc/Cargo.toml new file mode 100644 index 00000000..9338bf7d --- /dev/null +++ b/examples/todomvc/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "todomvc" +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"] } +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" +features = [ + "console", + "Window", + "Document", + "Element", + "Node", +] + +[workspace] 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..0cff1056 --- /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 as_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() { + 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.as_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), + ]) + }) + } +} diff --git a/examples/todomvc/src/lib.rs b/examples/todomvc/src/lib.rs new file mode 100644 index 00000000..42b7b3aa --- /dev/null +++ b/examples/todomvc/src/lib.rs @@ -0,0 +1,39 @@ +#![allow(clippy::unused_unit)] +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..dd2527ef --- /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) + } +}