//! The storage of the engine in the browser: IndexedDB. //! //! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object //! store per kind of data. A batch is a single transaction, so that the stored files always are //! ones that the engine had. The head is a number in the meta store, which the transaction of a //! batch checks before it writes anything (so that a tab that is behind cannot write over what //! another tab saved). use gpx_engine::{ Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData, }; use idb::TransactionMode; use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction}; use js_sys::Uint8Array; use uuid::Uuid; use wasm_bindgen::JsValue; const FILES: &str = "files"; const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks"; const WAYPOINT_CHUNKS: &str = "waypoint_chunks"; /// The order of the files and the head, under their own names. const META: &str = "meta"; const SETTINGS: &str = "settings"; const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS]; /// The stores of the files, which a commit writes to together with the head. const COMMIT_STORES: [&str; 4] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META]; const ORDER_KEY: &str = "order"; const HEAD_KEY: &str = "head"; /// Version of the database. const VERSION: u32 = 1; pub struct IdbStorage { database: Database, } fn error(error: impl std::fmt::Display) -> StorageError { StorageError::Failed(error.to_string()) } /// The object store of a kind of chunks. fn chunk_store(kind: ChunkKind) -> &'static str { match kind { ChunkKind::Trackpoints => TRACKPOINT_CHUNKS, ChunkKind::Waypoints => WAYPOINT_CHUNKS, } } fn key(id: Uuid) -> JsValue { JsValue::from_str(&id.to_string()) } fn bytes(value: &[u8]) -> JsValue { Uint8Array::from(value).into() } fn to_vec(value: &JsValue) -> Vec { Uint8Array::new(value).to_vec() } fn to_uuid(key: &JsValue) -> Result { key.as_string() .and_then(|key| Uuid::parse_str(&key).ok()) .ok_or_else(|| error("a key is not an id")) } impl IdbStorage { /// Opens the database, which is created if it does not exist. pub async fn open(name: &str) -> Result { let factory = Factory::new().map_err(error)?; let mut request = factory.open(name, Some(VERSION)).map_err(error)?; request.on_upgrade_needed(|event| { if let Ok(database) = event.database() { for store in STORES { let _ = database.create_object_store(store, ObjectStoreParams::new()); } } }); let database = request.await.map_err(error)?; Ok(Self { database }) } /// The head, 0 for a database that was never written to. async fn read_head(meta: &ObjectStore) -> Result { let head = meta .get(JsValue::from_str(HEAD_KEY)) .map_err(error)? .await .map_err(error)?; Ok(Revision( head.and_then(|head| head.as_f64()).unwrap_or_default() as u64, )) } fn transaction( &self, stores: &[&str], mode: TransactionMode, ) -> Result { self.database.transaction(stores, mode).map_err(error) } /// Everything stored in the object store, as `(key, bytes)`. async fn read_all(store: &ObjectStore) -> Result)>, StorageError> { let keys = store.get_all_keys(None, None).map_err(error)?; let values = store.get_all(None, None).map_err(error)?; let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?); Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect()) } /// Writes a batch in a transaction, if the head is the one it is based on. Gives the new head. async fn write( &self, transaction: &Transaction, batch: &Batch, ) -> Result { let meta = transaction.object_store(META).map_err(error)?; let head = Self::read_head(&meta).await?; if head != batch.expect_head { return Err(CommitError::Stale { head }); } let new_head = head.next(); let files = transaction.object_store(FILES).map_err(error)?; // the requests are all made before waiting for them let mut puts = vec![]; let mut deletes = vec![]; for (ChunkKey { kind, id }, data) in &batch.put_chunks { let store = transaction .object_store(chunk_store(*kind)) .map_err(error)?; puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?); } for (FileId(id), data) in &batch.put_files { puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?); } if let Some(order) = &batch.order { let value = bytes(order); puts.push( meta.put(&value, Some(&JsValue::from_str(ORDER_KEY))) .map_err(error)?, ); } puts.push( meta.put( &JsValue::from_f64(new_head.0 as f64), Some(&JsValue::from_str(HEAD_KEY)), ) .map_err(error)?, ); for FileId(id) in &batch.delete_files { deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?); } for ChunkKey { kind, id } in &batch.delete_chunks { let store = transaction .object_store(chunk_store(*kind)) .map_err(error)?; deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?); } for put in puts { put.await.map_err(error)?; } for delete in deletes { delete.await.map_err(error)?; } Ok(new_head) } } impl Storage for IdbStorage { async fn load(&self) -> Result { let transaction = self.transaction( &[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META], TransactionMode::ReadOnly, )?; let store = |name| transaction.object_store(name).map_err(error); let files = Self::read_all(&store(FILES)?).await?; let trackpoint_chunks = Self::read_all(&store(TRACKPOINT_CHUNKS)?).await?; let waypoint_chunks = Self::read_all(&store(WAYPOINT_CHUNKS)?).await?; let meta = store(META)?; let order = meta .get(JsValue::from_str(ORDER_KEY)) .map_err(error)? .await .map_err(error)?; let head = Self::read_head(&meta).await?; transaction.await.map_err(error)?; let ids = |records: Vec<(JsValue, Vec)>| -> Result)>, StorageError> { records .into_iter() .map(|(k, v)| Ok((to_uuid(&k)?, v))) .collect() }; Ok(StoredData { files: ids(files)? .into_iter() .map(|(id, data)| (FileId(id), data)) .collect(), chunks: ids(trackpoint_chunks)? .into_iter() .map(|(id, data)| (ChunkKey::trackpoints(id), data)) .chain( ids(waypoint_chunks)? .into_iter() .map(|(id, data)| (ChunkKey::waypoints(id), data)), ) .collect(), order: order.as_ref().map(to_vec), head, }) } async fn head(&self) -> Result { let transaction = self.transaction(&[META], TransactionMode::ReadOnly)?; let meta = transaction.object_store(META).map_err(error)?; let head = Self::read_head(&meta).await?; transaction.await.map_err(error)?; Ok(head) } async fn commit(&self, batch: &Batch) -> Result { let transaction = self.transaction(&COMMIT_STORES, TransactionMode::ReadWrite)?; match self.write(&transaction, batch).await { Ok(head) => { transaction.commit().map_err(error)?.await.map_err(error)?; Ok(head) } Err(err) => { // nothing of the batch is kept let _ = transaction.abort(); Err(err) } } } async fn load_settings(&self) -> Result, StorageError> { let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadOnly)?; let store = transaction.object_store(SETTINGS).map_err(error)?; let keys = store.get_all_keys(None, None).map_err(error)?; let values = store.get_all(None, None).map_err(error)?; let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?); transaction.await.map_err(error)?; Ok(keys .into_iter() .zip(values) .filter_map(|(key, value)| Some((key.as_string()?, value.as_string()?))) .collect()) } async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> { let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?; let store = transaction.object_store(SETTINGS).map_err(error)?; store .put(&JsValue::from_str(value), Some(&JsValue::from_str(key))) .map_err(error)? .await .map_err(error)?; transaction.commit().map_err(error)?.await.map_err(error)?; Ok(()) } async fn delete_setting(&self, key: &str) -> Result<(), StorageError> { let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?; let store = transaction.object_store(SETTINGS).map_err(error)?; store .delete(Query::Key(JsValue::from_str(key))) .map_err(error)? .await .map_err(error)?; transaction.commit().map_err(error)?.await.map_err(error)?; Ok(()) } }