mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-16 08:19:59 +00:00
cross-tab sync
This commit is contained in:
1 parent
77929cfa04
commit
a2b4d89150
9 files changed
+537
-71
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
|
||||
let cur_ids: HashSet<FileId> = 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Rc<File>>) -> crate::Restored {
|
||||
let mut files: Vec<Rc<File>> = 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());
|
||||
}
|
||||
@@ -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<FileId, Vec<u8>>,
|
||||
chunks: HashMap<ChunkKey, Vec<u8>>,
|
||||
order: Option<Vec<u8>>,
|
||||
@@ -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<Revision, StorageError> {
|
||||
Ok(self.state.borrow().head)
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
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<Vec<(String, String)>, 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)));
|
||||
}
|
||||
}
|
||||
@@ -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<StorageError> 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<u8>)>,
|
||||
/// The order of the files.
|
||||
pub order: Option<Vec<u8>>,
|
||||
/// 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<u8>)>,
|
||||
pub delete_chunks: Vec<ChunkKey>,
|
||||
pub order: Option<Vec<u8>>,
|
||||
/// 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<StoredData, StorageError>;
|
||||
|
||||
/// 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<Revision, StorageError>;
|
||||
|
||||
/// 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<Revision, CommitError>;
|
||||
|
||||
/// Reads the settings, as `(key, value)`. The engine does not look into them.
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
|
||||
|
||||
@@ -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<FileId>,
|
||||
chunks: HashSet<ChunkKey>,
|
||||
order: Option<Vec<FileId>>,
|
||||
/// 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<S: Storage>(
|
||||
&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<S: Storage>(&self, storage: &S) -> Result<bool, StorageError> {
|
||||
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<Revision, StorageError> {
|
||||
self.inner.head().await
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
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<Vec<(String, String)>, 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]
|
||||
|
||||
+59
-14
@@ -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<Self, StorageError> {
|
||||
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<Revision, StorageError> {
|
||||
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<Revision, CommitError> {
|
||||
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<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, 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<Revision, StorageError> {
|
||||
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<Revision, CommitError> {
|
||||
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
|
||||
|
||||
@@ -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<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
|
||||
@@ -24,6 +26,9 @@ thread_local! {
|
||||
static SAVING: Cell<bool> = const { Cell::new(false) };
|
||||
static DIRTY: Cell<bool> = 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<Option<(js_sys::Function, js_sys::Function)>> = const { RefCell::new(None) };
|
||||
static WAITERS: RefCell<Vec<js_sys::Function>> = 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<StorageOpened, JsValue> {
|
||||
pub async fn open_storage(
|
||||
name: &str,
|
||||
on_saved: js_sys::Function,
|
||||
on_behind: js_sys::Function,
|
||||
) -> Result<StorageOpened, JsValue> {
|
||||
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<StorageOpened, JsValue> {
|
||||
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<Outcome, JsValue> {
|
||||
// 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) {
|
||||
|
||||
Reference in new issue
Block a user