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
+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