cross-tab sync

This commit is contained in:
vcoppe committed 2026-10-10 11:07:07 +02:00
1 parent 77929cfa04
commit a2b4d89150
9 files changed
+537 -71

No files matched your search

+59 -14
View File
@@ -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
+78 -5
View File
@@ -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) {