Files
gpx.studio/gpx-rs/src/storage/mod.rs
T

231 lines
7.5 KiB
Rust
Raw Normal View History

2026-10-08 18:34:19 +02:00
//! Keeping the files and the settings between sessions.
//!
//! The engine does not know where the data goes: the host (the browser, a desktop app...)
//! implements [`Storage`], and a [`Persister`] works out what to write after each change.
//!
//! The files are not stored as a whole. Their trackpoints and waypoints are stored in chunks (see
//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record
//! 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.
2026-10-09 17:55:07 +02:00
//!
2026-10-10 11:07:07 +02:00
//! 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.
//!
2026-10-09 17:55:07 +02:00
//! 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
//! engines (tabs, a server) write to therefore has nothing to agree on but the chunks.
2026-10-08 18:34:19 +02:00
mod codec;
mod memory;
mod persister;
pub use memory::*;
pub use persister::*;
use uuid::Uuid;
use crate::FileId;
#[derive(Debug, Clone, PartialEq, Eq)]
2026-10-11 10:48:43 +02:00
pub enum StorageError {
/// The storage could not be read or written.
Failed(String),
/// Some of what is stored was written by a newer version of the app, which this one cannot
/// read. Nothing is written then, so that the newer version finds its data again.
Newer,
}
2026-10-08 18:34:19 +02:00
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2026-10-11 10:48:43 +02:00
match self {
Self::Failed(message) => write!(f, "storage error: {message}"),
Self::Newer => write!(f, "stored by a newer version"),
}
2026-10-08 18:34:19 +02:00
}
}
impl std::error::Error for StorageError {}
2026-10-10 11:07:07 +02:00
/// 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)
}
}
2026-10-08 21:04:29 +02:00
/// What a chunk holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChunkKind {
Trackpoints,
Waypoints,
}
/// Where a chunk is stored: chunks are told apart by their identity within their kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ChunkKey {
pub kind: ChunkKind,
pub id: Uuid,
}
impl ChunkKey {
pub fn trackpoints(id: Uuid) -> Self {
Self {
kind: ChunkKind::Trackpoints,
id,
}
}
pub fn waypoints(id: Uuid) -> Self {
Self {
kind: ChunkKind::Waypoints,
id,
}
}
}
2026-10-08 18:34:19 +02:00
/// Everything that is stored about the files, as opaque bytes (see the module documentation).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct StoredData {
/// The record of each file.
pub files: Vec<(FileId, Vec<u8>)>,
2026-10-08 21:04:29 +02:00
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
2026-10-08 18:34:19 +02:00
/// The order of the files.
pub order: Option<Vec<u8>>,
2026-10-10 11:07:07 +02:00
/// The revision of the storage that this is the state of.
pub head: Revision,
2026-10-08 18:34:19 +02:00
}
/// A change of the stored data, to be applied all at once: a stored state is always one that the
/// engine had.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Batch {
pub put_files: Vec<(FileId, Vec<u8>)>,
pub delete_files: Vec<FileId>,
2026-10-08 21:04:29 +02:00
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
pub delete_chunks: Vec<ChunkKey>,
2026-10-08 18:34:19 +02:00
pub order: Option<Vec<u8>>,
2026-10-10 11:07:07 +02:00
/// The head that the batch is based on: it is only applied if the head still is that one.
pub expect_head: Revision,
2026-10-08 18:34:19 +02:00
}
impl Batch {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
2026-10-08 21:04:29 +02:00
/// How many chunks of the kind the batch writes.
pub fn puts(&self, kind: ChunkKind) -> usize {
self.put_chunks
.iter()
.filter(|(key, _)| key.kind == kind)
.count()
}
/// How many chunks of the kind the batch deletes.
pub fn deletes(&self, kind: ChunkKind) -> usize {
self.delete_chunks
.iter()
.filter(|key| key.kind == kind)
.count()
}
2026-10-08 18:34:19 +02:00
}
/// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that
/// keeps nothing after the session.
///
/// The methods are async because storage often is (IndexedDB), the engine itself is not: it
/// changes its files at once and the changes are written afterwards, see [`Persister`].
#[allow(async_fn_in_trait)]
pub trait Storage {
/// Reads the files.
async fn load(&self) -> Result<StoredData, StorageError>;
2026-10-10 11:07:07 +02:00
/// 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>;
2026-10-08 18:34:19 +02:00
/// Reads the settings, as `(key, value)`. The engine does not look into them.
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError>;
async fn delete_setting(&self, key: &str) -> Result<(), StorageError>;
}
2026-10-08 19:04:46 +02:00
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_batch_is_empty_until_it_holds_something() {
assert!(Batch::default().is_empty());
let holds = [
Batch {
put_files: vec![(FileId::default(), vec![])],
..Default::default()
},
Batch {
delete_files: vec![FileId::default()],
..Default::default()
},
Batch {
2026-10-08 21:04:29 +02:00
put_chunks: vec![(ChunkKey::waypoints(Uuid::new_v4()), vec![])],
2026-10-08 19:04:46 +02:00
..Default::default()
},
Batch {
2026-10-08 21:04:29 +02:00
delete_chunks: vec![ChunkKey::trackpoints(Uuid::new_v4())],
2026-10-08 19:04:46 +02:00
..Default::default()
},
Batch {
order: Some(vec![]),
..Default::default()
},
];
assert!(holds.iter().all(|batch| !batch.is_empty()));
}
#[test]
fn test_storage_error_is_readable() {
2026-10-11 10:48:43 +02:00
let error = StorageError::Failed("disk full".into());
2026-10-08 19:04:46 +02:00
assert_eq!(error.to_string(), "storage error: disk full");
let _: &dyn std::error::Error = &error;
}
}