diff --git a/gpx-rs/engine/src/engine/mod.rs b/gpx-rs/engine/src/engine/mod.rs index 8a766c707..12f722673 100644 --- a/gpx-rs/engine/src/engine/mod.rs +++ b/gpx-rs/engine/src/engine/mod.rs @@ -129,32 +129,40 @@ impl Engine { } } - /// Puts files in an engine that has none, typically the ones read from the storage. They are - /// the state to start from: they cannot be undone. The files are reported as added, and the - /// order, the selection and the clipboard as changed. + /// Replaces what the engine holds by the files read from the storage: at the start, or when + /// another writer (a tab) committed since, which makes what was held here out of date. What + /// was here is gone, edits that were not saved included, and the files are the state to start + /// from: they cannot be undone, and nothing from before can be. + /// + /// The selection keeps what still exists, and so does the clipboard. The files that differ + /// from the ones that were here are reported as added, removed or modified (all the files + /// that were here, as they are not the same objects), and the order as changed if it is. pub fn restore(&mut self, restored: Restored) -> Outcome { + let selection_before = self.selection.clone(); + let order_before = self.order.0.clone(); + let before = self.stack.current().cloned(); let files: StackEntry = restored .files .into_iter() .map(|file| (file.id, file)) .collect(); + let diff = Stack::diff_between(before.as_ref(), Some(&files)); + let mut diff = + (!(diff.added.is_empty() && diff.removed.is_empty() && diff.modified.is_empty())) + .then_some(diff); self.categories = restored.categories; self.order = FileOrder(restored.order); - self.selection = Selection::Empty; - self.clipboard = None; - let mut diff = Some(Diff { - added: files.keys().copied().collect(), - ..Default::default() - }); self.stack = Stack::restored(files); self.refresh(diff.as_ref()); sort_diff(&mut diff, &self.order.0); let outcome = Outcome { - changed: true, + changed: diff.is_some() + || self.order.0 != order_before + || self.selection != selection_before, diff, - order_changed: true, - selection_changed: true, - clipboard_changed: true, + order_changed: self.order.0 != order_before, + selection_changed: self.selection != selection_before, + clipboard_changed: false, error: None, }; #[cfg(test)] diff --git a/gpx-rs/engine/src/engine/state/stack.rs b/gpx-rs/engine/src/engine/state/stack.rs index 71bac7e18..17b9da038 100644 --- a/gpx-rs/engine/src/engine/state/stack.rs +++ b/gpx-rs/engine/src/engine/state/stack.rs @@ -120,8 +120,14 @@ impl Stack { if prev == cur { return None; } - let prev = prev.map_or_default(|i| self.get(i)); - let cur = cur.map_or_default(|i| self.get(i)); + Some(Self::diff_between( + prev.map_or_default(|i| self.get(i)), + cur.map_or_default(|i| self.get(i)), + )) + } + + /// Which files differ between two states. + pub fn diff_between(prev: Option<&StackEntry>, cur: Option<&StackEntry>) -> Diff { let prev_ids: HashSet = prev.map_or_default(|e| e.keys().copied().collect()); let cur_ids: HashSet = cur.map_or_default(|e| e.keys().copied().collect()); let mut modified = vec![]; @@ -132,11 +138,11 @@ impl Stack { modified.push(*id); } } - Some(Diff { + Diff { added: cur_ids.difference(&prev_ids).copied().collect(), removed: prev_ids.difference(&cur_ids).copied().collect(), modified, - }) + } } } diff --git a/gpx-rs/engine/src/engine/tests.rs b/gpx-rs/engine/src/engine/tests.rs index c243e06c0..a93d7f1e8 100644 --- a/gpx-rs/engine/src/engine/tests.rs +++ b/gpx-rs/engine/src/engine/tests.rs @@ -1,7 +1,8 @@ use std::collections::HashSet; +use std::rc::Rc; use crate::{ - ClipboardIds, Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId, + ClipboardIds, Delete, DeleteAll, File, Load, Metadata, New, NewTrack, Paste, Style, TrackId, TrackSegmentId, }; @@ -1308,3 +1309,66 @@ fn test_execute_returns_what_the_action_did() { assert!(!outcome.changed); assert!(matches!(outcome.error, Some(CommandError::InvalidData(_)))); } + +// What the engine does when it is overwritten with what is stored + +fn restored_from(engine: &Engine, keep: &[FileId], extra: Vec>) -> crate::Restored { + let mut files: Vec> = keep + .iter() + .map(|id| engine.snapshot().files[id].clone()) + .collect(); + files.extend(extra); + crate::Restored { + order: files.iter().map(|file| file.id).collect(), + files, + categories: engine.categories().clone(), + unreadable: 0, + read_only: false, + } +} + +#[test] +fn test_overwriting_reports_what_was_added_removed_and_modified() { + let mut engine = Engine::default(); + new(&mut engine, "kept"); + new(&mut engine, "modified"); + new(&mut engine, "removed"); + let [kept, modified, removed] = engine.order()[..] else { + panic!() + }; + let mut changed = (*engine.snapshot().files[&modified]).clone(); + changed.info.name = "other name".into(); + let added = File::default(); + let added_id = added.id; + + let restored = restored_from(&engine, &[kept], vec![Rc::new(changed), Rc::new(added)]); + let outcome = engine.restore(restored); + assert!(outcome.changed); + let diff = outcome.diff.unwrap(); + assert_eq!(diff.added, vec![added_id]); + assert_eq!(diff.removed, vec![removed]); + assert_eq!(diff.modified, vec![modified]); + assert!(outcome.order_changed); + assert_eq!(engine.order(), &[kept, modified, added_id]); +} + +#[test] +fn test_overwriting_with_the_same_files_changes_nothing() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let ids = engine.order().to_vec(); + let outcome = engine.restore(restored_from(&engine, &ids, vec![])); + assert!(!outcome.changed); + assert!(outcome.diff.is_none()); +} + +#[test] +fn test_overwriting_forgets_the_history() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + new(&mut engine, "b"); + assert!(engine.can_undo()); + let ids = engine.order().to_vec(); + engine.restore(restored_from(&engine, &ids, vec![])); + assert!(!engine.can_undo()); +} diff --git a/gpx-rs/engine/src/storage/memory.rs b/gpx-rs/engine/src/storage/memory.rs index 61c181828..8395a8302 100644 --- a/gpx-rs/engine/src/storage/memory.rs +++ b/gpx-rs/engine/src/storage/memory.rs @@ -1,7 +1,9 @@ use std::cell::RefCell; use std::collections::HashMap; -use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData}; +use crate::{ + Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData, +}; /// A storage that lives as long as the session. It is what an engine without a host storage uses, /// and what the tests use. @@ -12,6 +14,7 @@ pub struct MemoryStorage { #[derive(Debug, Default)] struct State { + head: Revision, files: HashMap>, chunks: HashMap>, order: Option>, @@ -46,11 +49,19 @@ impl Storage for MemoryStorage { files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(), chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(), order: state.order.clone(), + head: state.head, }) } - async fn commit(&self, batch: &Batch) -> Result<(), StorageError> { + async fn head(&self) -> Result { + Ok(self.state.borrow().head) + } + + async fn commit(&self, batch: &Batch) -> Result { let mut state = self.state.borrow_mut(); + if state.head != batch.expect_head { + return Err(CommitError::Stale { head: state.head }); + } state.files.extend(batch.put_files.iter().cloned()); for id in &batch.delete_files { state.files.remove(id); @@ -62,7 +73,8 @@ impl Storage for MemoryStorage { if let Some(order) = &batch.order { state.order = Some(order.clone()); } - Ok(()) + state.head = state.head.next(); + Ok(state.head) } async fn load_settings(&self) -> Result, StorageError> { @@ -135,6 +147,7 @@ mod tests { (ChunkKey::waypoints(w), vec![4]), ], order: Some(vec![5]), + expect_head: Revision(0), ..Default::default() })) .unwrap(); @@ -158,6 +171,7 @@ mod tests { put_files: vec![(a, vec![10])], delete_files: vec![b], delete_chunks: vec![ChunkKey::trackpoints(t)], + expect_head: Revision(1), ..Default::default() })) .unwrap(); @@ -169,6 +183,7 @@ mod tests { block_on(storage.commit(&Batch { delete_chunks: vec![ChunkKey::waypoints(w)], order: Some(vec![]), + expect_head: Revision(2), ..Default::default() })) .unwrap(); @@ -204,4 +219,50 @@ mod tests { block_on(storage.put_setting("a", "1")).unwrap(); assert_eq!(block_on(storage.load()).unwrap(), StoredData::default()); } + + fn batch(expect_head: u64, entry: u8) -> Batch { + Batch { + put_files: vec![(FileId::default(), vec![entry])], + expect_head: Revision(expect_head), + ..Default::default() + } + } + + #[test] + fn test_every_commit_advances_the_head() { + let storage = MemoryStorage::default(); + assert_eq!(block_on(storage.head()).unwrap(), Revision(0)); + assert_eq!(block_on(storage.load()).unwrap().head, Revision(0)); + assert_eq!(block_on(storage.commit(&batch(0, 1))), Ok(Revision(1))); + assert_eq!(block_on(storage.commit(&batch(1, 2))), Ok(Revision(2))); + assert_eq!(block_on(storage.head()).unwrap(), Revision(2)); + assert_eq!(block_on(storage.load()).unwrap().head, Revision(2)); + } + + #[test] + fn test_a_commit_based_on_another_head_changes_nothing() { + let storage = MemoryStorage::default(); + block_on(storage.commit(&batch(0, 1))).unwrap(); + block_on(storage.commit(&batch(1, 2))).unwrap(); + let before = block_on(storage.load()).unwrap(); + + // behind, or ahead: the head is not the one it is based on + for expect in [0, 1, 3] { + let stale = Batch { + delete_files: vec![before.files[0].0], + order: Some(vec![9]), + ..batch(expect, 3) + }; + assert_eq!( + block_on(storage.commit(&stale)), + Err(CommitError::Stale { head: Revision(2) }) + ); + } + let after = block_on(storage.load()).unwrap(); + assert_eq!(after.head, Revision(2)); + assert_eq!(after.files.len(), before.files.len()); + assert_eq!(after.order, before.order); + // and it can be done again from the right head + assert_eq!(block_on(storage.commit(&batch(2, 3))), Ok(Revision(3))); + } } diff --git a/gpx-rs/engine/src/storage/mod.rs b/gpx-rs/engine/src/storage/mod.rs index 393d09527..6af66e978 100644 --- a/gpx-rs/engine/src/storage/mod.rs +++ b/gpx-rs/engine/src/storage/mod.rs @@ -8,6 +8,12 @@ //! that lists the chunks it is made of. An edit only writes the new chunks and the records of the //! files that changed; the chunks that nothing refers to anymore are deleted at the same time. //! +//! Several engines can write to a storage (tabs, a server). It has a head, a revision that each +//! commit advances, and a commit only applies if it is based on the current head: an engine that +//! is behind is refused, and has to read the storage again, which replaces what it holds, before +//! it writes. So what an engine knows of the storage is exact whenever it writes, which is what +//! lets it write only what changed and delete what it does not use anymore. +//! //! The surface, highway and scales of the trackpoints are stored as names, in the chunk that has //! them: the codes that the engine gives them (see [`crate::TrackpointCategories`]) depend on the //! order in which it learned the names, so they mean nothing outside of it. A storage that two @@ -35,6 +41,44 @@ impl std::fmt::Display for StorageError { impl std::error::Error for StorageError {} +/// A state of the storage: the head is the revision of the last commit, 0 for a storage that was +/// never written to. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Revision(pub u64); + +impl Revision { + pub fn next(self) -> Self { + Self(self.0 + 1) + } +} + +/// Why a commit did not happen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommitError { + /// The head is not the one the commit is based on: another engine wrote since. + Stale { + head: Revision, + }, + Failed(StorageError), +} + +impl std::fmt::Display for CommitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stale { head } => write!(f, "the storage changed (now at revision {})", head.0), + Self::Failed(error) => error.fmt(f), + } + } +} + +impl std::error::Error for CommitError {} + +impl From for CommitError { + fn from(error: StorageError) -> Self { + Self::Failed(error) + } +} + /// What a chunk holds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ChunkKind { @@ -73,6 +117,8 @@ pub struct StoredData { pub chunks: Vec<(ChunkKey, Vec)>, /// The order of the files. pub order: Option>, + /// The revision of the storage that this is the state of. + pub head: Revision, } /// A change of the stored data, to be applied all at once: a stored state is always one that the @@ -84,6 +130,8 @@ pub struct Batch { pub put_chunks: Vec<(ChunkKey, Vec)>, pub delete_chunks: Vec, pub order: Option>, + /// The head that the batch is based on: it is only applied if the head still is that one. + pub expect_head: Revision, } impl Batch { @@ -118,8 +166,12 @@ pub trait Storage { /// Reads the files. async fn load(&self) -> Result; - /// Applies a batch: either all of it is stored, or none. - async fn commit(&self, batch: &Batch) -> Result<(), StorageError>; + /// The head of the storage. + async fn head(&self) -> Result; + + /// Applies a batch if the head is the one it is based on (`Stale` otherwise): either all of it + /// is stored, or none. The head advances. Gives the new head. + async fn commit(&self, batch: &Batch) -> Result; /// Reads the settings, as `(key, value)`. The engine does not look into them. async fn load_settings(&self) -> Result, StorageError>; diff --git a/gpx-rs/engine/src/storage/persister.rs b/gpx-rs/engine/src/storage/persister.rs index c79204d57..51bff3dd0 100644 --- a/gpx-rs/engine/src/storage/persister.rs +++ b/gpx-rs/engine/src/storage/persister.rs @@ -9,8 +9,8 @@ use super::codec::{ encode_order, encode_trackpoints, encode_waypoints, }; use crate::{ - Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories, - TrackpointChunk, WaypointChunk, + Batch, ChunkKey, CommitError, File, FileId, Revision, StackEntry, Storage, StorageError, + TrackpointCategories, TrackpointChunk, WaypointChunk, }; /// What there is to keep of the engine: the files of the current state, their order, and the @@ -50,6 +50,8 @@ pub struct Persister { stored_files: HashSet, chunks: HashSet, order: Option>, + /// The head of the storage that all of this is the state of. + head: Revision, /// Set by `restore`, see [`Restored::read_only`]. read_only: bool, } @@ -156,26 +158,37 @@ impl Persister { self.chunks = chunk_bytes.into_keys().collect(); self.order = stored_order; restored.categories = categories.into_inner(); + self.head = data.head; self.read_only = newer.0.get(); restored.read_only = self.read_only; Ok(restored) } + /// The revision of the storage that the persister knows. + pub fn head(&self) -> Revision { + self.head + } + /// Writes what changed since the last save. Nothing is written if nothing changed. + /// + /// The write only happens if nothing else was committed since the persister read or wrote + /// last: otherwise it is refused (`Stale`), nothing is written, and the persister has to read + /// the storage again ([`Persister::restore`]) before it can write. pub async fn save( &mut self, storage: &S, snapshot: Snapshot, - ) -> Result<(), StorageError> { + ) -> Result<(), CommitError> { if self.read_only { return Ok(()); } - let plan = self.plan(snapshot); + let mut plan = self.plan(snapshot); if plan.batch.is_empty() { self.files = plan.snapshot.files; return Ok(()); } - storage.commit(&plan.batch).await?; + plan.batch.expect_head = self.head; + self.head = storage.commit(&plan.batch).await?; self.stored_files = plan.snapshot.files.keys().copied().collect(); self.files = plan.snapshot.files; @@ -184,6 +197,13 @@ impl Persister { Ok(()) } + /// Whether something else was committed to the storage since the persister read or wrote it + /// last: what it knows of the storage is out of date, and it cannot write until it reads + /// again ([`Persister::restore`]). + pub async fn is_behind(&self, storage: &S) -> Result { + Ok(storage.head().await? != self.head) + } + fn plan(&self, snapshot: Snapshot) -> Plan { let mut batch = Batch::default(); // the chunks that the files use, which the storage has afterwards @@ -268,9 +288,14 @@ mod tests { self.inner.load().await } - async fn commit(&self, batch: &Batch) -> Result<(), StorageError> { + async fn head(&self) -> Result { + self.inner.head().await + } + + async fn commit(&self, batch: &Batch) -> Result { + let revision = self.inner.commit(batch).await?; self.batches.borrow_mut().push(batch.clone()); - self.inner.commit(batch).await + Ok(revision) } async fn load_settings(&self) -> Result, StorageError> { @@ -441,6 +466,7 @@ mod tests { let broken = snapshot.order[0]; block_on(storage.inner.commit(&Batch { put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])], + expect_head: persister.head(), ..Default::default() })) .unwrap(); @@ -467,6 +493,7 @@ mod tests { block_on(Persister::default().save(&storage, snapshot.clone())).unwrap(); let mut batch = Batch::default(); damage(&snapshot, &mut batch); + batch.expect_head = block_on(storage.inner.head()).unwrap(); block_on(storage.inner.commit(&batch)).unwrap(); storage.batches.borrow_mut().clear(); (storage, snapshot) @@ -527,33 +554,130 @@ mod tests { assert_eq!(clean.inner.counts().files, FILES.len()); } + /// Opens a storage as a tab does: the persister, and the state of the engine it gives. + fn open(storage: &Recording) -> (Persister, Snapshot) { + let mut persister = Persister::default(); + let restored = block_on(persister.restore(storage)).unwrap(); + let snapshot = Snapshot { + files: restored.files.iter().map(|f| (f.id, f.clone())).collect(), + order: restored.order, + categories: restored.categories, + }; + (persister, snapshot) + } + + /// Adds a file that has just been read to the state of an engine. + fn add_file(snapshot: &mut Snapshot, path: &str) -> FileId { + let data = std::fs::read(path).unwrap(); + let file = parse(&data, &mut snapshot.categories).unwrap(); + let id = file.id; + snapshot.order.push(id); + snapshot.files.insert(id, Rc::new(file)); + id + } + #[test] fn test_files_saved_by_engines_with_other_categories_read_the_same() { - // two engines that share a storage (two tabs) learn the names in their own order + // a second engine opens the storage, so its categories come from what it read, in the + // order it read them; it adds a file that has names of its own let storage = Recording::default(); - let mut first = snapshot_of(&["data/with_surface.gpx", "data/with_highway.gpx"]); - let mut second = snapshot_of(&["data/with_highway.gpx", "data/with_surface.gpx"]); - assert_ne!(first.categories, second.categories); - for snapshot in [&first, &second] { - block_on(Persister::default().save(&storage, snapshot.clone())).unwrap(); - } + let first = snapshot_of(&["data/with_surface.gpx", "data/with_highway.gpx"]); + block_on(Persister::default().save(&storage, first.clone())).unwrap(); - let mut persister = Persister::default(); - let restored = block_on(persister.restore(&storage)).unwrap(); - assert_eq!(restored.unreadable, 0); - assert_eq!(restored.files.len(), 4); - // every file means what it did for the engine that saved it - first.files.extend(second.files.drain()); - for file in &restored.files { - let original = &first.files[&file.id]; - let in_first = first.order.contains(&file.id); - let categories = if in_first { - &first.categories - } else { - &second.categories + let (mut persister, mut second) = open(&storage); + let added = add_file(&mut second, "data/with_surface.gpx"); + block_on(persister.save(&storage, second.clone())).unwrap(); + + // the first engine reads it all again, and every file means what it did for its engine + let (_, reader) = open(&storage); + assert_eq!(reader.files.len(), 3); + for (id, file) in &reader.files { + let (original, categories) = match first.files.get(id) { + Some(original) => (original, &first.categories), + None => (&second.files[id], &second.categories), }; - assert_eq!(gpx(file, &restored.categories), gpx(original, categories)); + assert_eq!(gpx(file, &reader.categories), gpx(original, categories)); } + assert!(reader.files.contains_key(&added)); + } + + #[test] + fn test_the_head_follows_the_commits_of_the_persister() { + let storage = Recording::default(); + let mut persister = Persister::default(); + assert_eq!(persister.head(), Revision(0)); + + let mut snapshot = snapshot_of(&FILES); + block_on(persister.save(&storage, snapshot.clone())).unwrap(); + assert_eq!(persister.head(), Revision(1)); + assert_eq!(block_on(storage.head()).unwrap(), Revision(1)); + + // nothing to write is no commit + block_on(persister.save(&storage, snapshot.clone())).unwrap(); + assert_eq!(persister.head(), Revision(1)); + assert_eq!(storage.batches.borrow().len(), 1); + + let id = snapshot.order[0]; + let mut file = (*snapshot.files[&id]).clone(); + file.info.name = "renamed".into(); + snapshot.files.insert(id, Rc::new(file)); + block_on(persister.save(&storage, snapshot)).unwrap(); + assert_eq!(persister.head(), Revision(2)); + // a persister that opens the storage starts from its head + assert_eq!(open(&storage).0.head(), Revision(2)); + assert_eq!(storage.batches.borrow()[1].expect_head, Revision(1)); + } + + #[test] + fn test_a_tab_that_is_behind_cannot_write() { + // two tabs open the same file; one edits a point, the other renames the file + let storage = Recording::default(); + block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap(); + let ((mut a, mut snap_a), (mut b, mut snap_b)) = (open(&storage), open(&storage)); + let id = snap_a.order[0]; + + let mut file = (*snap_a.files[&id]).clone(); + file.trk[0].trkseg[0].update(0, |p| p.ele += 100.0); + snap_a.files.insert(id, Rc::new(file)); + block_on(a.save(&storage, snap_a.clone())).unwrap(); + let stored = block_on(storage.load()).unwrap(); + + let mut file = (*snap_b.files[&id]).clone(); + file.info.name = "renamed by B".into(); + snap_b.files.insert(id, Rc::new(file)); + assert_eq!( + block_on(b.save(&storage, snap_b)), + Err(CommitError::Stale { head: Revision(2) }) + ); + // nothing was written, the file is still readable, and the persister did not move + assert_eq!(block_on(storage.load()).unwrap(), stored); + assert_eq!(b.head(), Revision(1)); + let (_, reader) = open(&storage); + assert_eq!(reader.files.len(), 1); + assert_eq!(reader.files[&id].info.name, snap_a.files[&id].info.name); + } + + #[test] + fn test_a_tab_that_is_behind_reads_the_storage_again_and_writes_again() { + let storage = Recording::default(); + block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap(); + let ((mut a, mut snap_a), (mut b, snap_b)) = (open(&storage), open(&storage)); + let id = snap_a.order[0]; + assert!(!block_on(b.is_behind(&storage)).unwrap()); + + let mut file = (*snap_a.files[&id]).clone(); + file.info.name = "renamed by A".into(); + snap_a.files.insert(id, Rc::new(file)); + block_on(a.save(&storage, snap_a)).unwrap(); + assert!(block_on(b.is_behind(&storage)).unwrap()); + assert!(!block_on(a.is_behind(&storage)).unwrap()); + + let restored = block_on(b.restore(&storage)).unwrap(); + assert_eq!(restored.files[0].info.name, "renamed by A"); + assert!(!block_on(b.is_behind(&storage)).unwrap()); + let mut snapshot = snap_b; + snapshot.files.insert(id, restored.files[0].clone()); + assert!(block_on(b.save(&storage, snapshot)).is_ok()); } #[test] diff --git a/gpx-rs/wasm/src/idb.rs b/gpx-rs/wasm/src/idb.rs index f435e5276..888d328ff 100644 --- a/gpx-rs/wasm/src/idb.rs +++ b/gpx-rs/wasm/src/idb.rs @@ -2,9 +2,13 @@ //! //! 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. +//! 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, FileId, Storage, StorageError, StoredData}; +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; @@ -14,12 +18,17 @@ 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, under its own name. +/// 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, @@ -59,7 +68,7 @@ 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(1)).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 { @@ -71,6 +80,18 @@ impl IdbStorage { 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], @@ -87,9 +108,19 @@ impl IdbStorage { Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect()) } - async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> { - let files = transaction.object_store(FILES).map_err(error)?; + /// 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![]; @@ -110,6 +141,13 @@ impl IdbStorage { .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)?); } @@ -125,7 +163,7 @@ impl IdbStorage { for delete in deletes { delete.await.map_err(error)?; } - Ok(()) + Ok(new_head) } } @@ -145,6 +183,7 @@ impl Storage for IdbStorage { .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> { @@ -168,18 +207,24 @@ impl Storage for IdbStorage { ) .collect(), order: order.as_ref().map(to_vec), + head, }) } - async fn commit(&self, batch: &Batch) -> Result<(), StorageError> { - let transaction = self.transaction( - &[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META], - TransactionMode::ReadWrite, - )?; + 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(()) => { + Ok(head) => { transaction.commit().map_err(error)?.await.map_err(error)?; - Ok(()) + Ok(head) } Err(err) => { // nothing of the batch is kept diff --git a/gpx-rs/wasm/src/persistence.rs b/gpx-rs/wasm/src/persistence.rs index d68e0f27c..9f9fce712 100644 --- a/gpx-rs/wasm/src/persistence.rs +++ b/gpx-rs/wasm/src/persistence.rs @@ -10,13 +10,15 @@ use wasm_bindgen::prelude::*; use crate::convert::{outcome_object, set}; use crate::idb::IdbStorage; use crate::session::*; -use crate::ts::StorageOpened; +use crate::ts::{Outcome, StorageOpened}; // Storage // // The files are saved in IndexedDB after each change that touches them, without making the change // wait: `schedule_save` starts a loop that writes what changed since the last write, until -// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them. +// nothing has. When another tab wrote in between, the write is refused (the storage has a newer +// head than this tab last saw): the tab reads the storage again and overwrites what it holds, see +// `sync_storage`. The settings are opaque to the engine, the frontend keeps its own copy of them. thread_local! { static STORAGE: RefCell>> = const { RefCell::new(None) }; @@ -24,6 +26,9 @@ thread_local! { static SAVING: Cell = const { Cell::new(false) }; static DIRTY: Cell = const { Cell::new(false) }; /// Resolve functions of the promises of `flush_storage`. + /// Called after the files were written, and when a write was refused because they were + /// written by someone else since: see `open_storage`. + static LISTENERS: RefCell> = const { RefCell::new(None) }; static WAITERS: RefCell> = const { RefCell::new(Vec::new()) }; } @@ -47,6 +52,7 @@ pub(crate) fn schedule_save() { return; } wasm_bindgen_futures::spawn_local(async { + let (mut saved, mut behind) = (false, false); while DIRTY.replace(false) { let Some(storage) = storage() else { break }; let Some(snapshot) = with_engine(Engine::snapshot) else { @@ -57,25 +63,52 @@ pub(crate) fn schedule_save() { .unwrap_or_default(); let result = persister.save(&*storage, snapshot).await; PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); - if let Err(error) = result { + match result { + Ok(()) => saved = true, + // nothing was written, so that the files of the other tab are not damaged: this + // tab has to read them, which replaces what it did + Err(engine::CommitError::Stale { .. }) => { + DIRTY.set(false); + behind = true; + } // what failed is written with the next change - console_error(&error.to_string()); + Err(error) => console_error(&error.to_string()), } } SAVING.set(false); + if saved { + notify(|(saved, _)| saved); + } + if behind { + notify(|(_, behind)| behind); + } for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) { let _ = resolve.call0(&JsValue::UNDEFINED); } }); } +fn notify(pick: impl FnOnce(&(js_sys::Function, js_sys::Function)) -> &js_sys::Function) { + let callback = LISTENERS.with(|l| l.borrow().as_ref().map(|l| pick(l).clone())); + if let Some(callback) = callback { + let _ = callback.call0(&JsValue::UNDEFINED); + } +} + /// Opens the database `name` (it is created if it does not exist), puts the files that it holds /// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the /// outcome of putting the files in. The changes of the files that follow are saved, unless some of /// what is stored was written by a newer version of the app (`readOnly`): then nothing is, so that /// the newer version finds its data again. +/// +/// `on_saved` is called after the files were written (to tell the other tabs), and `on_behind` when +/// a write was refused because another tab wrote since (call `sync_storage` then). #[wasm_bindgen] -pub async fn open_storage(name: &str) -> Result { +pub async fn open_storage( + name: &str, + on_saved: js_sys::Function, + on_behind: js_sys::Function, +) -> Result { let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false); if !empty { return Err(JsValue::from_str("the engine already has files")); @@ -89,6 +122,7 @@ pub async fn open_storage(name: &str) -> Result { let outcome = with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default(); PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage))); + LISTENERS.with(|slot| *slot.borrow_mut() = Some((on_saved, on_behind))); let settings_object = Object::new(); for (key, value) in settings { @@ -119,6 +153,45 @@ pub async fn flush_storage() -> Result<(), JsValue> { Ok(()) } +/// If another tab wrote the files since this one last did, reads them again and overwrites the +/// files of the engine with them (what was changed here and not saved is lost, and the history +/// starts over). Returns what that changed, to apply like the outcome of any other call. +#[wasm_bindgen] +pub async fn sync_storage() -> Result { + // the writes are done first, and none starts before this is done + while SAVING.get() { + flush_storage().await?; + } + let (Some(storage), Some(mut persister)) = ( + storage(), + PERSISTER.with(|persister| persister.borrow_mut().take()), + ) else { + return Ok(outcome_object(&engine::Outcome::default(), None)); + }; + SAVING.set(true); + let result = async { + if !persister.is_behind(&*storage).await? { + return Ok(engine::Outcome::default()); + } + let restored = persister.restore(&*storage).await?; + DIRTY.set(false); + Ok(with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default()) + } + .await; + PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); + SAVING.set(false); + for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) { + let _ = resolve.call0(&JsValue::UNDEFINED); + } + if DIRTY.get() { + // edited while reading, and nothing was overwritten + schedule_save(); + } + result + .map(|outcome| outcome_object(&outcome, None)) + .map_err(to_js_error) +} + /// Saves a setting, `value` being its JSON. #[wasm_bindgen] pub fn set_setting(key: String, value: String) { diff --git a/website/src/lib/engine/storage.ts b/website/src/lib/engine/storage.ts index 622764853..8316757e5 100644 --- a/website/src/lib/engine/storage.ts +++ b/website/src/lib/engine/storage.ts @@ -6,6 +6,9 @@ import type { Wasm } from './convert'; /** Name of the IndexedDB database that keeps the files and the settings. */ const STORAGE_NAME = 'gpx-studio'; +/** Name of the channel that tells the tabs that the files were written. */ +const CHANNEL_NAME = 'gpx-studio-storage'; + /** What opening the storage needs of the engine facade. */ export type StorageHost = { wasm: Wasm; @@ -18,6 +21,35 @@ export type StorageHost = { deselect(): Promise; }; +/** + * Keeps the files of the engine as the ones that another tab saved: when a tab writes, the others + * are told, and read the storage again if it has changed since they last did, which overwrites + * what they hold (see `sync_storage`). The same happens when a write of this tab is refused + * because of that, and when the tab is shown again (a hidden tab can miss messages). + */ +function followOtherTabs(host: StorageHost): { saved: () => void; behind: () => void } { + const { wasm } = host; + let reading = Promise.resolve(); + const refresh = () => { + reading = reading + .then(async () => host.sync(await wasm.sync_storage())) + .catch((error) => console.error('The files could not be read again', error)); + }; + const channel = + typeof BroadcastChannel === 'undefined' ? undefined : new BroadcastChannel(CHANNEL_NAME); + if (channel) { + channel.onmessage = refresh; + } + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + refresh(); + } + }); + } + return { saved: () => channel?.postMessage('saved'), behind: refresh }; +} + /** * Opens the storage of the browser: the files that it holds are put in the engine, and the * changes of the files are kept from then on. Resolves to the settings, as JSON strings by key. @@ -27,8 +59,9 @@ export type StorageHost = { export async function openStoredFiles(host: StorageHost): Promise> { const { wasm } = host; let opened: StorageOpened; + const tabs = followOtherTabs(host); try { - opened = await wasm.open_storage(STORAGE_NAME); + opened = await wasm.open_storage(STORAGE_NAME, tabs.saved, tabs.behind); } catch (error) { console.error('The files cannot be kept in this browser', error); return {};