From 41dce46b0cebf40591037be4c3882340cb420215 Mon Sep 17 00:00:00 2001 From: Daniel Salinas Date: Thu, 18 Jun 2026 19:52:45 +0000 Subject: [PATCH 1/5] fix(indexeddb): reopen event-cache & media connections when the browser closes them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IndexedDB stores held a single long-lived `IDBDatabase` handle and treated `close()`/`reopen()` as no-ops, with no listener for a connection-level close. When the browser closes the connection out from under us (a transient storage backend error, eviction, or a `versionchange` from another context), every subsequent transaction fails for the lifetime of the process — the store is unable to read or write until the app restarts. For the event cache this is especially damaging: persistence runs on a decoupled task whose errors are only logged while sync keeps advancing, so dropped events are never re-requested and timelines fail to load until logout/login. Introduce a shared, reopenable connection and apply it to the event cache and media stores: - Add `connection::IndexeddbConnection`: an `Rc>>` handle with a generic `with_transaction(stores, mode, open, body)` that builds the transaction, and on a DOM `InvalidStateError` (connection closing/closed) reopens via `open` and retries exactly once. The owning `Rc` is kept alive on the helper's stack frame for the duration of `body`, so the borrowed transaction is sound — no unsafe, no background task. - Route every event-cache and media transaction through the helper and implement their `reopen()` methods. - Add `From` to the event-cache and media error types so they satisfy the helper's error bound. --- crates/matrix-sdk-indexeddb/src/connection.rs | 139 ++++ .../src/event_cache_store/builder.rs | 6 +- .../src/event_cache_store/error.rs | 6 + .../src/event_cache_store/mod.rs | 610 ++++++++++-------- crates/matrix-sdk-indexeddb/src/lib.rs | 1 + .../src/media_store/builder.rs | 6 +- .../src/media_store/error.rs | 6 + .../src/media_store/mod.rs | 392 ++++++----- 8 files changed, 728 insertions(+), 438 deletions(-) create mode 100644 crates/matrix-sdk-indexeddb/src/connection.rs diff --git a/crates/matrix-sdk-indexeddb/src/connection.rs b/crates/matrix-sdk-indexeddb/src/connection.rs new file mode 100644 index 00000000000..cbe0c78dfa8 --- /dev/null +++ b/crates/matrix-sdk-indexeddb/src/connection.rs @@ -0,0 +1,139 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License + +//! A reopenable handle to an IndexedDB database, shared by every IndexedDB store +//! in this crate. +//! +//! The browser can close an `IDBDatabase` out from under us — a transient +//! storage backend error, eviction under storage pressure, or a `versionchange` +//! from another browsing context. Once that happens, the handle is permanently +//! unusable: every subsequent transaction fails for the lifetime of the +//! process, leaving the store unable to read or write until the app restarts. +//! +//! [`IndexeddbConnection`] holds the handle behind shared interior mutability so +//! it can be reopened in place, with the fresh handle observed by every clone of +//! the owning store, and provides a [`with_transaction`] helper that reopens and +//! retries once when it detects that the connection was closed. +//! +//! [`with_transaction`]: IndexeddbConnection::with_transaction + +use std::{cell::RefCell, future::Future, rc::Rc}; + +use indexed_db_futures::{ + Build, + database::Database, + error::{DomException, Error}, + transaction::{Transaction, TransactionMode}, +}; + +/// A reopenable handle to a single IndexedDB database. +#[derive(Debug, Clone)] +pub struct IndexeddbConnection { + /// The name of the database, retained so the connection can be reopened. + name: String, + /// The live handle. The outer `Rc` is shared by every clone of the owning + /// store; the inner `Rc` is the handle itself, cloned out for the duration + /// of each transaction so a concurrent reopen can swap the slot without + /// invalidating an in-flight transaction. + inner: Rc>>, +} + +impl IndexeddbConnection { + /// Wrap a freshly opened database in a reopenable connection. + pub fn new(name: String, database: Database) -> Self { + Self { name, inner: Rc::new(RefCell::new(Rc::new(database))) } + } + + /// The name of the underlying database. + pub fn name(&self) -> &str { + &self.name + } + + /// The current live handle. + pub fn database(&self) -> Rc { + self.inner.borrow().clone() + } + + /// Reopen the connection via `open`, replacing the shared handle. + /// + /// Exposed for the `reopen()` store-trait methods; the transaction path + /// reopens lazily via [`with_transaction`](Self::with_transaction). + pub async fn reopen(&self, open: Open) -> Result<(), E> + where + Open: FnOnce(String) -> OpenFut, + OpenFut: Future>, + { + let database = Rc::new(open(self.name.clone()).await?); + *self.inner.borrow_mut() = database; + Ok(()) + } + + /// Run `body` against a fresh transaction over `stores`. + /// + /// If building the transaction fails because the connection has been closed + /// by the browser, the handle is reopened via `open` and the transaction is + /// retried exactly once. Any other failure — and any error returned by + /// `body` itself — is propagated unchanged. + /// + /// The owning `Rc` is kept alive on this stack frame for the whole + /// duration of `body`, so the transaction handed to `body` borrows a handle + /// that outlives it. This keeps a transient connection loss from + /// permanently bricking the store without any `unsafe` or background task. + pub async fn with_transaction( + &self, + stores: &[&str], + mode: TransactionMode, + open: Open, + body: Body, + ) -> Result + where + E: From, + Open: FnOnce(String) -> OpenFut, + OpenFut: Future>, + Body: AsyncFnOnce(Transaction<'_>) -> Result, + { + // First attempt on the current handle. The `Ref` from `borrow()` is + // released immediately; only the cloned `Rc` is held across the await. + { + let database = self.inner.borrow().clone(); + match database.transaction(stores).with_mode(mode).build() { + Ok(transaction) => return body(transaction).await, + Err(error) => { + if !is_connection_closed(&error) { + return Err(error.into()); + } + // The connection was closed; fall through to reopen and + // retry once. `database` is dropped at the end of this block. + } + } + } + + let database = Rc::new(open(self.name.clone()).await?); + *self.inner.borrow_mut() = database.clone(); + + let transaction = database.transaction(stores).with_mode(mode).build()?; + body(transaction).await + } +} + +/// Whether `error` indicates that the underlying IndexedDB connection is no +/// longer usable (closing or closed), as opposed to a logical error on an +/// otherwise healthy connection. +/// +/// Building a transaction on a closed `IDBDatabase` raises a DOM +/// `InvalidStateError`; that is the signal that the handle must be reopened +/// before retrying. +pub fn is_connection_closed(error: &Error) -> bool { + matches!(error, Error::DomException(DomException::InvalidStateError(_))) +} diff --git a/crates/matrix-sdk-indexeddb/src/event_cache_store/builder.rs b/crates/matrix-sdk-indexeddb/src/event_cache_store/builder.rs index 16703d4949d..d9e065b9644 100644 --- a/crates/matrix-sdk-indexeddb/src/event_cache_store/builder.rs +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/builder.rs @@ -17,11 +17,12 @@ // type, at which point the line below can be removed. #![allow(dead_code)] -use std::{rc::Rc, sync::Arc}; +use std::sync::Arc; use matrix_sdk_store_encryption::StoreCipher; use crate::{ + connection::IndexeddbConnection, event_cache_store::{ IndexeddbEventCacheStore, error::IndexeddbEventCacheStoreError, migrations::open_and_upgrade_db, @@ -78,8 +79,9 @@ impl IndexeddbEventCacheStoreBuilder { /// opened, builds the [`IndexeddbEventCacheStore`] with that database /// and the provided store cipher. pub async fn build(self) -> Result { + let database = open_and_upgrade_db(&self.database_name).await?; Ok(IndexeddbEventCacheStore { - inner: Rc::new(open_and_upgrade_db(&self.database_name).await?), + connection: IndexeddbConnection::new(self.database_name, database), serializer: IndexedTypeSerializer::new(SafeEncodeSerializer::new(self.store_cipher)), }) } diff --git a/crates/matrix-sdk-indexeddb/src/event_cache_store/error.rs b/crates/matrix-sdk-indexeddb/src/event_cache_store/error.rs index 4c6447eaa55..8dc5a57c99d 100644 --- a/crates/matrix-sdk-indexeddb/src/event_cache_store/error.rs +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/error.rs @@ -50,6 +50,12 @@ impl From for IndexeddbEventCacheStoreError { } } +impl From for IndexeddbEventCacheStoreError { + fn from(value: indexed_db_futures::error::Error) -> Self { + TransactionError::from(value).into() + } +} + impl From for IndexeddbEventCacheStoreError { fn from(value: indexed_db_futures::error::OpenDbError) -> Self { use indexed_db_futures::error::OpenDbError::*; diff --git a/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs b/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs index 82dadb7f6b3..317414d38bf 100644 --- a/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs @@ -14,9 +14,8 @@ #![cfg_attr(not(test), allow(unused))] -use std::{collections::HashMap, rc::Rc, time::Duration}; +use std::{collections::HashMap, time::Duration}; -use indexed_db_futures::{Build, database::Database}; #[cfg(target_family = "wasm")] use matrix_sdk_base::cross_process_lock::{ CrossProcessLockGeneration, FIRST_CROSS_PROCESS_LOCK_GENERATION, @@ -36,8 +35,9 @@ use tracing::{error, instrument, trace}; use web_sys::IdbTransactionMode; use crate::{ + connection::IndexeddbConnection, event_cache_store::{ - migrations::current::keys, + migrations::{current::keys, open_and_upgrade_db}, transaction::IndexeddbEventCacheStoreTransaction, types::{ChunkType, InBandEvent, Lease, OutOfBandEvent}, }, @@ -64,8 +64,9 @@ pub use error::IndexeddbEventCacheStoreError; /// [1]: matrix_sdk_base::event_cache::store::EventCacheStore #[derive(Debug, Clone)] pub struct IndexeddbEventCacheStore { - // A handle to the IndexedDB database - inner: Rc, + // A reopenable handle to the IndexedDB database. See [`IndexeddbConnection`] + // for why the handle must be reopenable rather than a plain `Database`. + connection: IndexeddbConnection, // A serializer with functionality tailored to `IndexeddbEventCacheStore` serializer: IndexedTypeSerializer, } @@ -77,22 +78,36 @@ impl IndexeddbEventCacheStore { IndexeddbEventCacheStoreBuilder::default() } - /// Initializes a new transaction on the underlying IndexedDB database and - /// returns a handle which can be used to combine database operations - /// into an atomic unit. - pub fn transaction<'a>( - &'a self, + /// Run `f` against a fresh transaction over `stores`, reopening the + /// connection and retrying once if the browser has closed it. + /// + /// Without this, a transient connection loss would permanently brick the + /// event cache: every read (timelines fail to load) and every write + /// (incoming events silently dropped) would keep failing until the app is + /// restarted. + async fn with_transaction( + &self, stores: &[&str], mode: IdbTransactionMode, - ) -> Result, IndexeddbEventCacheStoreError> { - Ok(IndexeddbEventCacheStoreTransaction::new( - self.inner - .transaction(stores) - .with_mode(mode) - .build() - .map_err(TransactionError::from)?, - &self.serializer, - )) + f: F, + ) -> Result + where + F: AsyncFnOnce( + IndexeddbEventCacheStoreTransaction<'_>, + ) -> Result, + { + self.connection + .with_transaction( + stores, + mode, + |name| async move { + open_and_upgrade_db(&name).await.map_err(IndexeddbEventCacheStoreError::from) + }, + async |transaction| { + f(IndexeddbEventCacheStoreTransaction::new(transaction, &self.serializer)).await + }, + ) + .await } } @@ -108,54 +123,58 @@ impl EventCacheStore for IndexeddbEventCacheStore { key: &str, holder: &str, ) -> Result, IndexeddbEventCacheStoreError> { - let transaction = - self.transaction(&[Lease::OBJECT_STORE], IdbTransactionMode::Readwrite)?; - - let now = Duration::from_millis(MilliSecondsSinceUnixEpoch::now().get().into()); - let expiration = now + Duration::from_millis(lease_duration_ms.into()); - - let lease = match transaction.get_lease_by_id(key).await? { - Some(mut lease) => { - if lease.holder == holder { - // We had the lease before, extend it. - lease.expiration = expiration; - - Some(lease) - } else { - // We didn't have it. - if lease.expiration < now { - // Steal it! - lease.holder = holder.to_owned(); - lease.expiration = expiration; - lease.generation += 1; + self.with_transaction( + &[Lease::OBJECT_STORE], + IdbTransactionMode::Readwrite, + async move |transaction| { + let now = Duration::from_millis(MilliSecondsSinceUnixEpoch::now().get().into()); + let expiration = now + Duration::from_millis(lease_duration_ms.into()); + + let lease = match transaction.get_lease_by_id(key).await? { + Some(mut lease) => { + if lease.holder == holder { + // We had the lease before, extend it. + lease.expiration = expiration; + + Some(lease) + } else { + // We didn't have it. + if lease.expiration < now { + // Steal it! + lease.holder = holder.to_owned(); + lease.expiration = expiration; + lease.generation += 1; + + Some(lease) + } else { + // We tried our best. + None + } + } + } + None => { + let lease = Lease { + key: key.to_owned(), + holder: holder.to_owned(), + expiration, + generation: FIRST_CROSS_PROCESS_LOCK_GENERATION, + }; Some(lease) - } else { - // We tried our best. - None } - } - } - None => { - let lease = Lease { - key: key.to_owned(), - holder: holder.to_owned(), - expiration, - generation: FIRST_CROSS_PROCESS_LOCK_GENERATION, }; - Some(lease) - } - }; + Ok(if let Some(lease) = lease { + transaction.put_lease(&lease).await?; + transaction.commit().await?; - Ok(if let Some(lease) = lease { - transaction.put_lease(&lease).await?; - transaction.commit().await?; - - Some(lease.generation) - } else { - None - }) + Some(lease.generation) + } else { + None + }) + }, + ) + .await } #[instrument(skip(self, updates))] @@ -166,13 +185,12 @@ impl EventCacheStore for IndexeddbEventCacheStore { ) -> Result<(), IndexeddbEventCacheStoreError> { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[keys::LINKED_CHUNKS, keys::GAPS, keys::EVENTS], IdbTransactionMode::Readwrite, - )?; - - for update in updates { - match update { + async move |transaction| { + for update in updates { + match update { Update::NewItemsChunk { previous, new, next } => { trace!(%linked_chunk_id, "Inserting new chunk (prev={previous:?}, new={new:?}, next={next:?})"); transaction @@ -267,10 +285,13 @@ impl EventCacheStore for IndexeddbEventCacheStore { transaction.delete_events_by_linked_chunk_id(linked_chunk_id).await?; transaction.delete_gaps_by_linked_chunk_id(linked_chunk_id).await?; } - } - } - transaction.commit().await?; - Ok(()) + } + } + transaction.commit().await?; + Ok(()) + }, + ) + .await } #[instrument(skip(self))] @@ -280,22 +301,24 @@ impl EventCacheStore for IndexeddbEventCacheStore { ) -> Result>, IndexeddbEventCacheStoreError> { let _ = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[keys::LINKED_CHUNKS, keys::GAPS, keys::EVENTS], IdbTransactionMode::Readwrite, - )?; - - let mut raw_chunks = Vec::new(); - let chunks = transaction.get_chunks_by_linked_chunk_id(linked_chunk_id).await?; - for chunk in chunks { - if let Some(raw_chunk) = transaction - .load_chunk_by_id(linked_chunk_id, ChunkIdentifier::new(chunk.identifier)) - .await? - { - raw_chunks.push(raw_chunk); - } - } - Ok(raw_chunks) + async move |transaction| { + let mut raw_chunks = Vec::new(); + let chunks = transaction.get_chunks_by_linked_chunk_id(linked_chunk_id).await?; + for chunk in chunks { + if let Some(raw_chunk) = transaction + .load_chunk_by_id(linked_chunk_id, ChunkIdentifier::new(chunk.identifier)) + .await? + { + raw_chunks.push(raw_chunk); + } + } + Ok(raw_chunks) + }, + ) + .await } #[instrument(skip(self))] @@ -315,25 +338,27 @@ impl EventCacheStore for IndexeddbEventCacheStore { // https://github.com/matrix-org/matrix-rust-sdk/pull/5382. let _ = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[keys::LINKED_CHUNKS, keys::EVENTS, keys::GAPS], IdbTransactionMode::Readwrite, - )?; - - let mut raw_chunks = Vec::new(); - let chunks = transaction.get_chunks_by_linked_chunk_id(linked_chunk_id).await?; - for chunk in chunks { - let chunk_id = ChunkIdentifier::new(chunk.identifier); - let num_items = - transaction.get_events_count_by_chunk(linked_chunk_id, chunk_id).await?; - raw_chunks.push(ChunkMetadata { - num_items, - previous: chunk.previous.map(ChunkIdentifier::new), - identifier: ChunkIdentifier::new(chunk.identifier), - next: chunk.next.map(ChunkIdentifier::new), - }); - } - Ok(raw_chunks) + async move |transaction| { + let mut raw_chunks = Vec::new(); + let chunks = transaction.get_chunks_by_linked_chunk_id(linked_chunk_id).await?; + for chunk in chunks { + let chunk_id = ChunkIdentifier::new(chunk.identifier); + let num_items = + transaction.get_events_count_by_chunk(linked_chunk_id, chunk_id).await?; + raw_chunks.push(ChunkMetadata { + num_items, + previous: chunk.previous.map(ChunkIdentifier::new), + identifier: ChunkIdentifier::new(chunk.identifier), + next: chunk.next.map(ChunkIdentifier::new), + }); + } + Ok(raw_chunks) + }, + ) + .await } #[instrument(skip(self))] @@ -346,50 +371,54 @@ impl EventCacheStore for IndexeddbEventCacheStore { > { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[keys::LINKED_CHUNKS, keys::EVENTS, keys::GAPS], IdbTransactionMode::Readonly, - )?; - - if transaction.get_chunks_count_by_linked_chunk_id(linked_chunk_id).await? == 0 { - return Ok((None, ChunkIdentifierGenerator::new_from_scratch())); - } - // Now that we know we have some chunks in the room, we query IndexedDB - // for the last chunk in the room by getting the chunk which does not - // have a next chunk. - match transaction.get_chunk_by_next_chunk_id(linked_chunk_id, None).await { - Err(TransactionError::ItemIsNotUnique) => { - // If there are multiple chunks that do not have a next chunk, that - // means we have more than one last chunk, which means that we have - // more than one list in the room. - Err(IndexeddbEventCacheStoreError::ChunksContainDisjointLists) - } - Err(e) => { - // There was some error querying IndexedDB, but it is not necessarily - // a violation of our data constraints. - Err(e.into()) - } - Ok(None) => { - // If there is no chunk without a next chunk, that means every chunk - // points to another chunk, which means that we have a cycle in our list. - Err(IndexeddbEventCacheStoreError::ChunksContainCycle) - } - Ok(Some(last_chunk)) => { - let last_chunk_identifier = ChunkIdentifier::new(last_chunk.identifier); - let last_raw_chunk = transaction - .load_chunk_by_id(linked_chunk_id, last_chunk_identifier) - .await? - .ok_or(IndexeddbEventCacheStoreError::UnableToLoadChunk)?; - let max_chunk_id = transaction - .get_max_chunk_by_id(linked_chunk_id) - .await? - .map(|chunk| ChunkIdentifier::new(chunk.identifier)) - .ok_or(IndexeddbEventCacheStoreError::NoMaxChunkId)?; - let generator = - ChunkIdentifierGenerator::new_from_previous_chunk_identifier(max_chunk_id); - Ok((Some(last_raw_chunk), generator)) - } - } + async move |transaction| { + if transaction.get_chunks_count_by_linked_chunk_id(linked_chunk_id).await? == 0 { + return Ok((None, ChunkIdentifierGenerator::new_from_scratch())); + } + // Now that we know we have some chunks in the room, we query IndexedDB + // for the last chunk in the room by getting the chunk which does not + // have a next chunk. + match transaction.get_chunk_by_next_chunk_id(linked_chunk_id, None).await { + Err(TransactionError::ItemIsNotUnique) => { + // If there are multiple chunks that do not have a next chunk, that + // means we have more than one last chunk, which means that we have + // more than one list in the room. + Err(IndexeddbEventCacheStoreError::ChunksContainDisjointLists) + } + Err(e) => { + // There was some error querying IndexedDB, but it is not necessarily + // a violation of our data constraints. + Err(e.into()) + } + Ok(None) => { + // If there is no chunk without a next chunk, that means every chunk + // points to another chunk, which means that we have a cycle in our list. + Err(IndexeddbEventCacheStoreError::ChunksContainCycle) + } + Ok(Some(last_chunk)) => { + let last_chunk_identifier = ChunkIdentifier::new(last_chunk.identifier); + let last_raw_chunk = transaction + .load_chunk_by_id(linked_chunk_id, last_chunk_identifier) + .await? + .ok_or(IndexeddbEventCacheStoreError::UnableToLoadChunk)?; + let max_chunk_id = transaction + .get_max_chunk_by_id(linked_chunk_id) + .await? + .map(|chunk| ChunkIdentifier::new(chunk.identifier)) + .ok_or(IndexeddbEventCacheStoreError::NoMaxChunkId)?; + let generator = + ChunkIdentifierGenerator::new_from_previous_chunk_identifier( + max_chunk_id, + ); + Ok((Some(last_raw_chunk), generator)) + } + } + }, + ) + .await } #[instrument(skip(self))] @@ -400,34 +429,40 @@ impl EventCacheStore for IndexeddbEventCacheStore { ) -> Result>, IndexeddbEventCacheStoreError> { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[keys::LINKED_CHUNKS, keys::EVENTS, keys::GAPS], IdbTransactionMode::Readonly, - )?; - if let Some(chunk) = - transaction.get_chunk_by_id(linked_chunk_id, before_chunk_identifier).await? - && let Some(previous_identifier) = chunk.previous - { - let previous_identifier = ChunkIdentifier::new(previous_identifier); - Ok(transaction.load_chunk_by_id(linked_chunk_id, previous_identifier).await?) - } else { - Ok(None) - } + async move |transaction| { + if let Some(chunk) = + transaction.get_chunk_by_id(linked_chunk_id, before_chunk_identifier).await? + && let Some(previous_identifier) = chunk.previous + { + let previous_identifier = ChunkIdentifier::new(previous_identifier); + Ok(transaction.load_chunk_by_id(linked_chunk_id, previous_identifier).await?) + } else { + Ok(None) + } + }, + ) + .await } #[instrument(skip(self))] async fn clear_all_events(&self) -> Result<(), IndexeddbEventCacheStoreError> { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[keys::LINKED_CHUNKS, keys::EVENTS, keys::GAPS], IdbTransactionMode::Readwrite, - )?; - transaction.clear::().await?; - transaction.clear::().await?; - transaction.clear::().await?; - transaction.commit().await?; - Ok(()) + async move |transaction| { + transaction.clear::().await?; + transaction.clear::().await?; + transaction.clear::().await?; + transaction.commit().await?; + Ok(()) + }, + ) + .await } #[instrument(skip(self, events))] @@ -442,16 +477,22 @@ impl EventCacheStore for IndexeddbEventCacheStore { return Ok(Vec::new()); } - let transaction = self.transaction(&[keys::EVENTS], IdbTransactionMode::Readonly)?; - let mut duplicated = Vec::new(); - for event_id in events { - if let Some(types::Event::InBand(event)) = - transaction.get_event_by_id(linked_chunk_id, &event_id).await? - { - duplicated.push((event_id, event.position.into())); - } - } - Ok(duplicated) + self.with_transaction( + &[keys::EVENTS], + IdbTransactionMode::Readonly, + async move |transaction| { + let mut duplicated = Vec::new(); + for event_id in events { + if let Some(types::Event::InBand(event)) = + transaction.get_event_by_id(linked_chunk_id, &event_id).await? + { + duplicated.push((event_id, event.position.into())); + } + } + Ok(duplicated) + }, + ) + .await } #[instrument(skip(self, event_id))] @@ -462,12 +503,18 @@ impl EventCacheStore for IndexeddbEventCacheStore { ) -> Result, IndexeddbEventCacheStoreError> { let _timer = timer!("method"); - let transaction = self.transaction(&[keys::EVENTS], IdbTransactionMode::Readonly)?; - transaction - .get_events_by_room(room_id, event_id) - .await - .map(|mut events| events.pop().map(Into::into)) - .map_err(Into::into) + self.with_transaction( + &[keys::EVENTS], + IdbTransactionMode::Readonly, + async move |transaction| { + transaction + .get_events_by_room(room_id, event_id) + .await + .map(|mut events| events.pop().map(Into::into)) + .map_err(Into::into) + }, + ) + .await } #[instrument(skip(self, event_id, filters))] @@ -479,62 +526,70 @@ impl EventCacheStore for IndexeddbEventCacheStore { ) -> Result)>, IndexeddbEventCacheStoreError> { let _timer = timer!("method"); - let transaction = self.transaction(&[keys::EVENTS], IdbTransactionMode::Readonly)?; - - let mut related_events = HashMap::::new(); - match filters { - Some(relation_types) if !relation_types.is_empty() => { - for relation_type in relation_types { - let relation = (event_id, relation_type); - let events = transaction.get_events_by_relation(room_id, relation).await?; - for event in events { - let Some(event_id) = event.event_id() else { - return Err(IndexeddbEventCacheStoreError::EventWithoutId); - }; - match event.linked_chunk_id() { - LinkedChunkId::Room(_) => { - // Prioritize events that come from a room linked chunk - related_events.insert(event_id.to_owned(), event); - } - _ => { - // Remove position information from events that come - // from any other type of linked chunk - related_events - .entry(event_id.to_owned()) - .or_insert_with(|| event.into_out_of_band_event()); + self.with_transaction( + &[keys::EVENTS], + IdbTransactionMode::Readonly, + async move |transaction| { + let mut related_events = HashMap::::new(); + match filters { + Some(relation_types) if !relation_types.is_empty() => { + for relation_type in relation_types { + let relation = (event_id, relation_type); + let events = + transaction.get_events_by_relation(room_id, relation).await?; + for event in events { + let Some(event_id) = event.event_id() else { + return Err(IndexeddbEventCacheStoreError::EventWithoutId); + }; + match event.linked_chunk_id() { + LinkedChunkId::Room(_) => { + // Prioritize events that come from a room linked chunk + related_events.insert(event_id.to_owned(), event); + } + _ => { + // Remove position information from events that come + // from any other type of linked chunk + related_events + .entry(event_id.to_owned()) + .or_insert_with(|| event.into_out_of_band_event()); + } + } } } } - } - } - _ => { - for event in transaction.get_events_by_related_event(room_id, event_id).await? { - let Some(event_id) = event.event_id() else { - return Err(IndexeddbEventCacheStoreError::EventWithoutId); - }; - match event.linked_chunk_id() { - LinkedChunkId::Room(_) => { - // Prioritize events that come from a room linked chunk - related_events.insert(event_id.to_owned(), event); - } - _ => { - // Remove position information from events that come - // from any other type of linked chunk - related_events - .entry(event_id.to_owned()) - .or_insert_with(|| event.into_out_of_band_event()); + _ => { + for event in + transaction.get_events_by_related_event(room_id, event_id).await? + { + let Some(event_id) = event.event_id() else { + return Err(IndexeddbEventCacheStoreError::EventWithoutId); + }; + match event.linked_chunk_id() { + LinkedChunkId::Room(_) => { + // Prioritize events that come from a room linked chunk + related_events.insert(event_id.to_owned(), event); + } + _ => { + // Remove position information from events that come + // from any other type of linked chunk + related_events + .entry(event_id.to_owned()) + .or_insert_with(|| event.into_out_of_band_event()); + } + } } } } - } - } - Ok(related_events - .into_values() - .map(|event| { - let position = event.position().map(Into::into); - (event.into(), position) - }) - .collect()) + Ok(related_events + .into_values() + .map(|event| { + let position = event.position().map(Into::into); + (event.into(), position) + }) + .collect()) + }, + ) + .await } #[instrument(skip(self))] @@ -549,29 +604,35 @@ impl EventCacheStore for IndexeddbEventCacheStore { // TODO: Make this more efficient so we don't load all events and filter them // here. We should instead only load the relevant events. - let transaction = self.transaction(&[keys::EVENTS], IdbTransactionMode::Readonly)?; - transaction - .get_room_events(room_id) - .await - .map(|mut vec| { - vec.dedup_by(|a, b| { - if let (Some(a), Some(b)) = (a.event_id(), b.event_id()) { - a == b - } else { - false - } - }); - vec.into_iter() - .map(Event::from) - .filter(|e| { - event_type.is_none_or(|event_type| { - Some(event_type) == e.kind.event_type().as_deref() - }) + self.with_transaction( + &[keys::EVENTS], + IdbTransactionMode::Readonly, + async move |transaction| { + transaction + .get_room_events(room_id) + .await + .map(|mut vec| { + vec.dedup_by(|a, b| { + if let (Some(a), Some(b)) = (a.event_id(), b.event_id()) { + a == b + } else { + false + } + }); + vec.into_iter() + .map(Event::from) + .filter(|e| { + event_type.is_none_or(|event_type| { + Some(event_type) == e.kind.event_type().as_deref() + }) + }) + .filter(|e| session_id.is_none_or(|s| Some(s) == e.kind.session_id())) + .collect() }) - .filter(|e| session_id.is_none_or(|s| Some(s) == e.kind.session_id())) - .collect() - }) - .map_err(Into::into) + .map_err(Into::into) + }, + ) + .await } #[instrument(skip(self, event))] @@ -586,26 +647,32 @@ impl EventCacheStore for IndexeddbEventCacheStore { error!(%room_id, "Trying to save an event with no ID"); return Ok(()); }; - let transaction = self.transaction(&[keys::EVENTS], IdbTransactionMode::Readwrite)?; - - let mut events = transaction - .get_events_by_room(room_id, event_id) - .await? - .into_iter() - .map(|e| e.with_content(event.clone())) - .collect::>(); - if events.is_empty() { - events.push(types::Event::OutOfBand(OutOfBandEvent { - linked_chunk_id: LinkedChunkId::Room(room_id).to_owned(), - content: event, - position: (), - })); - } - for event in events { - transaction.put_event(&event).await?; - } - transaction.commit().await?; - Ok(()) + let event_id = event_id.to_owned(); + self.with_transaction( + &[keys::EVENTS], + IdbTransactionMode::Readwrite, + async move |transaction| { + let mut events = transaction + .get_events_by_room(room_id, &event_id) + .await? + .into_iter() + .map(|e| e.with_content(event.clone())) + .collect::>(); + if events.is_empty() { + events.push(types::Event::OutOfBand(OutOfBandEvent { + linked_chunk_id: LinkedChunkId::Room(room_id).to_owned(), + content: event, + position: (), + })); + } + for event in events { + transaction.put_event(&event).await?; + } + transaction.commit().await?; + Ok(()) + }, + ) + .await } async fn optimize(&self) -> Result<(), Self::Error> { @@ -621,6 +688,11 @@ impl EventCacheStore for IndexeddbEventCacheStore { } async fn reopen(&self) -> Result<(), Self::Error> { + self.connection + .reopen(|name| async move { + open_and_upgrade_db(&name).await.map_err(IndexeddbEventCacheStoreError::from) + }) + .await?; Ok(()) } } diff --git a/crates/matrix-sdk-indexeddb/src/lib.rs b/crates/matrix-sdk-indexeddb/src/lib.rs index 755f25dc690..cc2cf3db4e5 100644 --- a/crates/matrix-sdk-indexeddb/src/lib.rs +++ b/crates/matrix-sdk-indexeddb/src/lib.rs @@ -4,6 +4,7 @@ use matrix_sdk_base::store::StoreError; use thiserror::Error; +mod connection; #[cfg(feature = "e2e-encryption")] mod crypto_store; mod error; diff --git a/crates/matrix-sdk-indexeddb/src/media_store/builder.rs b/crates/matrix-sdk-indexeddb/src/media_store/builder.rs index 1d1526b9678..938a96458ba 100644 --- a/crates/matrix-sdk-indexeddb/src/media_store/builder.rs +++ b/crates/matrix-sdk-indexeddb/src/media_store/builder.rs @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License -use std::{rc::Rc, sync::Arc}; +use std::sync::Arc; use matrix_sdk_base::media::store::MediaService; use matrix_sdk_store_encryption::StoreCipher; use crate::{ + connection::IndexeddbConnection, media_store::{ IndexeddbMediaStore, error::IndexeddbMediaStoreError, migrations::open_and_upgrade_db, }, @@ -73,8 +74,9 @@ impl IndexeddbMediaStoreBuilder { /// opened, builds the [`IndexeddbMediaStore`] with that database /// and the provided store cipher. pub async fn build(self) -> Result { + let database = open_and_upgrade_db(&self.database_name).await?; Ok(IndexeddbMediaStore { - inner: Rc::new(open_and_upgrade_db(&self.database_name).await?), + connection: IndexeddbConnection::new(self.database_name, database), serializer: IndexedTypeSerializer::new(SafeEncodeSerializer::new(self.store_cipher)), media_service: MediaService::new(), }) diff --git a/crates/matrix-sdk-indexeddb/src/media_store/error.rs b/crates/matrix-sdk-indexeddb/src/media_store/error.rs index 31e07d6969e..5bb12cba2f7 100644 --- a/crates/matrix-sdk-indexeddb/src/media_store/error.rs +++ b/crates/matrix-sdk-indexeddb/src/media_store/error.rs @@ -52,6 +52,12 @@ impl From for IndexeddbMediaStoreError { } } +impl From for IndexeddbMediaStoreError { + fn from(value: indexed_db_futures::error::Error) -> Self { + TransactionError::from(value).into() + } +} + impl From for IndexeddbMediaStoreError { fn from(value: indexed_db_futures::error::OpenDbError) -> Self { use indexed_db_futures::error::OpenDbError::*; diff --git a/crates/matrix-sdk-indexeddb/src/media_store/mod.rs b/crates/matrix-sdk-indexeddb/src/media_store/mod.rs index 314a4f5d82d..fd2e5fe1759 100644 --- a/crates/matrix-sdk-indexeddb/src/media_store/mod.rs +++ b/crates/matrix-sdk-indexeddb/src/media_store/mod.rs @@ -24,13 +24,11 @@ mod migrations; mod serializer; mod transaction; mod types; -use std::{rc::Rc, time::Duration}; +use std::time::Duration; pub use builder::IndexeddbMediaStoreBuilder; pub use error::IndexeddbMediaStoreError; -use indexed_db_futures::{ - Build, cursor::CursorDirection, database::Database, transaction::TransactionMode, -}; +use indexed_db_futures::{cursor::CursorDirection, transaction::TransactionMode}; #[cfg(target_family = "wasm")] use matrix_sdk_base::cross_process_lock::{ CrossProcessLockGeneration, FIRST_CROSS_PROCESS_LOCK_GENERATION, @@ -49,12 +47,13 @@ use ruma::{MilliSecondsSinceUnixEpoch, MxcUri, time::SystemTime}; use tracing::instrument; use crate::{ + connection::IndexeddbConnection, media_store::{ + migrations::open_and_upgrade_db, transaction::IndexeddbMediaStoreTransaction, types::{Lease, Media, MediaCleanupTime, MediaContent, MediaMetadata, UnixTime}, }, serializer::indexed_type::{IndexedTypeSerializer, traits::Indexed}, - transaction::TransactionError, }; /// A type for providing an IndexedDB implementation of [`MediaStore`][1]. @@ -64,8 +63,9 @@ use crate::{ /// [1]: matrix_sdk_base::media::store::MediaStore #[derive(Debug, Clone)] pub struct IndexeddbMediaStore { - // A handle to the IndexedDB database - inner: Rc, + // A reopenable handle to the IndexedDB database. See [`IndexeddbConnection`] + // for why the handle must be reopenable rather than a plain `Database`. + connection: IndexeddbConnection, // A serializer with functionality tailored to `IndexeddbMediaStore` serializer: IndexedTypeSerializer, // A service for conveniently delegating media-related queries to an `MediaStoreInner` @@ -80,22 +80,29 @@ impl IndexeddbMediaStore { IndexeddbMediaStoreBuilder::default() } - /// Initializes a new transaction on the underlying IndexedDB database and - /// returns a handle which can be used to combine database operations - /// into an atomic unit. - pub fn transaction<'a>( - &'a self, + /// Run `f` against a fresh transaction over `stores`, reopening the + /// connection and retrying once if the browser has closed it. + async fn with_transaction( + &self, stores: &[&str], mode: TransactionMode, - ) -> Result, IndexeddbMediaStoreError> { - Ok(IndexeddbMediaStoreTransaction::new( - self.inner - .transaction(stores) - .with_mode(mode) - .build() - .map_err(TransactionError::from)?, - &self.serializer, - )) + f: F, + ) -> Result + where + F: AsyncFnOnce(IndexeddbMediaStoreTransaction<'_>) -> Result, + { + self.connection + .with_transaction( + stores, + mode, + |name| async move { + open_and_upgrade_db(&name).await.map_err(IndexeddbMediaStoreError::from) + }, + async |transaction| { + f(IndexeddbMediaStoreTransaction::new(transaction, &self.serializer)).await + }, + ) + .await } } @@ -111,53 +118,58 @@ impl MediaStore for IndexeddbMediaStore { key: &str, holder: &str, ) -> Result, IndexeddbMediaStoreError> { - let transaction = self.transaction(&[Lease::OBJECT_STORE], TransactionMode::Readwrite)?; - - let now = Duration::from_millis(MilliSecondsSinceUnixEpoch::now().get().into()); - let expiration = now + Duration::from_millis(lease_duration_ms.into()); - - let lease = match transaction.get_lease_by_id(key).await? { - Some(mut lease) => { - if lease.holder == holder { - // We had the lease before, extend it. - lease.expiration = expiration; - - Some(lease) - } else { - // We didn't have it. - if lease.expiration < now { - // Steal it! - lease.holder = holder.to_owned(); - lease.expiration = expiration; - lease.generation += 1; + self.with_transaction( + &[Lease::OBJECT_STORE], + TransactionMode::Readwrite, + async move |transaction| { + let now = Duration::from_millis(MilliSecondsSinceUnixEpoch::now().get().into()); + let expiration = now + Duration::from_millis(lease_duration_ms.into()); + + let lease = match transaction.get_lease_by_id(key).await? { + Some(mut lease) => { + if lease.holder == holder { + // We had the lease before, extend it. + lease.expiration = expiration; + + Some(lease) + } else { + // We didn't have it. + if lease.expiration < now { + // Steal it! + lease.holder = holder.to_owned(); + lease.expiration = expiration; + lease.generation += 1; + + Some(lease) + } else { + // We tried our best. + None + } + } + } + None => { + let lease = Lease { + key: key.to_owned(), + holder: holder.to_owned(), + expiration, + generation: FIRST_CROSS_PROCESS_LOCK_GENERATION, + }; Some(lease) - } else { - // We tried our best. - None } - } - } - None => { - let lease = Lease { - key: key.to_owned(), - holder: holder.to_owned(), - expiration, - generation: FIRST_CROSS_PROCESS_LOCK_GENERATION, }; - Some(lease) - } - }; - - Ok(if let Some(lease) = lease { - transaction.put_lease(&lease).await?; - transaction.commit().await?; + Ok(if let Some(lease) = lease { + transaction.put_lease(&lease).await?; + transaction.commit().await?; - Some(lease.generation) - } else { - None - }) + Some(lease.generation) + } else { + None + }) + }, + ) + .await } #[instrument(skip_all)] @@ -179,16 +191,21 @@ impl MediaStore for IndexeddbMediaStore { ) -> Result<(), IndexeddbMediaStoreError> { let _timer = timer!("method"); - let transaction = - self.transaction(&[MediaMetadata::OBJECT_STORE], TransactionMode::Readwrite)?; - if let Some(mut metadata) = transaction.get_media_metadata_by_id(from).await? { - // delete before adding, in case `from` and `to` generate the same key - transaction.delete_media_metadata_by_id(from).await?; - metadata.request_parameters = to.clone(); - transaction.add_media_metadata(&metadata).await?; - transaction.commit().await?; - } - Ok(()) + self.with_transaction( + &[MediaMetadata::OBJECT_STORE], + TransactionMode::Readwrite, + async move |transaction| { + if let Some(mut metadata) = transaction.get_media_metadata_by_id(from).await? { + // delete before adding, in case `from` and `to` generate the same key + transaction.delete_media_metadata_by_id(from).await?; + metadata.request_parameters = to.clone(); + transaction.add_media_metadata(&metadata).await?; + transaction.commit().await?; + } + Ok(()) + }, + ) + .await } #[instrument(skip_all)] @@ -207,12 +224,15 @@ impl MediaStore for IndexeddbMediaStore { ) -> Result<(), IndexeddbMediaStoreError> { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE], TransactionMode::Readwrite, - )?; - transaction.delete_media_by_id(request).await?; - transaction.commit().await.map_err(Into::into) + async move |transaction| { + transaction.delete_media_by_id(request).await?; + transaction.commit().await.map_err(Into::into) + }, + ) + .await } #[instrument(skip(self))] @@ -231,12 +251,15 @@ impl MediaStore for IndexeddbMediaStore { ) -> Result<(), IndexeddbMediaStoreError> { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE], TransactionMode::Readwrite, - )?; - transaction.delete_media_by_uri(uri).await?; - transaction.commit().await.map_err(Into::into) + async move |transaction| { + transaction.delete_media_by_uri(uri).await?; + transaction.commit().await.map_err(Into::into) + }, + ) + .await } #[instrument(skip_all)] @@ -283,7 +306,11 @@ impl MediaStore for IndexeddbMediaStore { } async fn reopen(&self) -> Result<(), Self::Error> { - Ok(()) + self.connection + .reopen(|name| async move { + open_and_upgrade_db(&name).await.map_err(IndexeddbMediaStoreError::from) + }) + .await } } @@ -297,10 +324,14 @@ impl MediaStoreInner for IndexeddbMediaStore { &self, ) -> Result, IndexeddbMediaStoreError> { let _timer = timer!("method"); - self.transaction(&[MediaRetentionPolicy::OBJECT_STORE], TransactionMode::Readonly)? - .get_media_retention_policy() - .await - .map_err(Into::into) + self.with_transaction( + &[MediaRetentionPolicy::OBJECT_STORE], + TransactionMode::Readonly, + async move |transaction| { + transaction.get_media_retention_policy().await.map_err(Into::into) + }, + ) + .await } #[instrument(skip_all)] @@ -310,10 +341,15 @@ impl MediaStoreInner for IndexeddbMediaStore { ) -> Result<(), IndexeddbMediaStoreError> { let _timer = timer!("method"); - let transaction = - self.transaction(&[MediaRetentionPolicy::OBJECT_STORE], TransactionMode::Readwrite)?; - transaction.put_item(&policy).await?; - transaction.commit().await.map_err(Into::into) + self.with_transaction( + &[MediaRetentionPolicy::OBJECT_STORE], + TransactionMode::Readwrite, + async move |transaction| { + transaction.put_item(&policy).await?; + transaction.commit().await.map_err(Into::into) + }, + ) + .await } #[instrument(skip_all)] @@ -327,20 +363,22 @@ impl MediaStoreInner for IndexeddbMediaStore { ) -> Result<(), IndexeddbMediaStoreError> { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE], TransactionMode::Readwrite, - )?; - - let media = Media { - request_parameters: request.clone(), - last_access: current_time.into(), - ignore_policy, - content, - }; + async move |transaction| { + let media = Media { + request_parameters: request.clone(), + last_access: current_time.into(), + ignore_policy, + content, + }; - transaction.put_media_if_policy_compliant(media, policy).await?; - transaction.commit().await.map_err(Into::into) + transaction.put_media_if_policy_compliant(media, policy).await?; + transaction.commit().await.map_err(Into::into) + }, + ) + .await } #[instrument(skip_all)] @@ -351,16 +389,21 @@ impl MediaStoreInner for IndexeddbMediaStore { ) -> Result<(), IndexeddbMediaStoreError> { let _timer = timer!("method"); - let transaction = - self.transaction(&[MediaMetadata::OBJECT_STORE], TransactionMode::Readwrite)?; - if let Some(mut metadata) = transaction.get_media_metadata_by_id(request).await? - && metadata.ignore_policy != ignore_policy - { - metadata.ignore_policy = ignore_policy; - transaction.put_media_metadata(&metadata).await?; - transaction.commit().await?; - } - Ok(()) + self.with_transaction( + &[MediaMetadata::OBJECT_STORE], + TransactionMode::Readwrite, + async move |transaction| { + if let Some(mut metadata) = transaction.get_media_metadata_by_id(request).await? + && metadata.ignore_policy != ignore_policy + { + metadata.ignore_policy = ignore_policy; + transaction.put_media_metadata(&metadata).await?; + transaction.commit().await?; + } + Ok(()) + }, + ) + .await } #[instrument(skip_all)] @@ -371,13 +414,16 @@ impl MediaStoreInner for IndexeddbMediaStore { ) -> Result>, IndexeddbMediaStoreError> { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE], TransactionMode::Readwrite, - )?; - let media = transaction.access_media_by_id(request, current_time).await?; - transaction.commit().await?; - Ok(media.map(|m| m.content)) + async move |transaction| { + let media = transaction.access_media_by_id(request, current_time).await?; + transaction.commit().await?; + Ok(media.map(|m| m.content)) + }, + ) + .await } #[instrument(skip_all)] @@ -388,13 +434,16 @@ impl MediaStoreInner for IndexeddbMediaStore { ) -> Result>, IndexeddbMediaStoreError> { let _timer = timer!("method"); - let transaction = self.transaction( + self.with_transaction( &[MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE], TransactionMode::Readwrite, - )?; - let media = transaction.access_media_by_uri(uri, current_time).await?.pop(); - transaction.commit().await?; - Ok(media.map(|m| m.content)) + async move |transaction| { + let media = transaction.access_media_by_uri(uri, current_time).await?.pop(); + transaction.commit().await?; + Ok(media.map(|m| m.content)) + }, + ) + .await } #[instrument(skip_all)] @@ -409,62 +458,70 @@ impl MediaStoreInner for IndexeddbMediaStore { return Ok(()); } - let transaction = self.transaction( + self.with_transaction( &[ MediaMetadata::OBJECT_STORE, MediaContent::OBJECT_STORE, MediaCleanupTime::OBJECT_STORE, ], TransactionMode::Readwrite, - )?; - - let ignore_policy = IgnoreMediaRetentionPolicy::No; - let current_time = UnixTime::from(current_time); - - if let Some(max_file_size) = policy.computed_max_file_size() { - transaction - .delete_media_by_content_size_greater_than(ignore_policy, max_file_size as usize) - .await?; - } + async move |transaction| { + let ignore_policy = IgnoreMediaRetentionPolicy::No; + let current_time = UnixTime::from(current_time); - if let Some(expiry) = policy.last_access_expiry { - transaction - .delete_media_by_last_access_earlier_than(ignore_policy, current_time - expiry) - .await?; - } + if let Some(max_file_size) = policy.computed_max_file_size() { + transaction + .delete_media_by_content_size_greater_than( + ignore_policy, + max_file_size as usize, + ) + .await?; + } - if let Some(max_cache_size) = policy.max_cache_size { - let cache_size = transaction - .get_cache_size(ignore_policy) - .await? - .ok_or(Self::Error::CacheSizeTooBig)?; - if cache_size > (max_cache_size as usize) { - let (_, upper_key) = transaction - .fold_media_metadata_keys_by_retention_while( - CursorDirection::Prev, - ignore_policy, - 0usize, - |total, key| match total.checked_add(key.content_size()) { - None => None, - Some(total) if total > max_cache_size as usize => None, - Some(total) => Some(total), - }, - ) - .await?; - if let Some(upper_key) = upper_key { + if let Some(expiry) = policy.last_access_expiry { transaction - .delete_media_by_retention_metadata_to( - upper_key.ignore_policy(), - upper_key.last_access(), - upper_key.content_size(), + .delete_media_by_last_access_earlier_than( + ignore_policy, + current_time - expiry, ) .await?; } - } - } - transaction.put_media_cleanup_time(current_time).await?; - transaction.commit().await.map_err(Into::into) + if let Some(max_cache_size) = policy.max_cache_size { + let cache_size = transaction + .get_cache_size(ignore_policy) + .await? + .ok_or(IndexeddbMediaStoreError::CacheSizeTooBig)?; + if cache_size > (max_cache_size as usize) { + let (_, upper_key) = transaction + .fold_media_metadata_keys_by_retention_while( + CursorDirection::Prev, + ignore_policy, + 0usize, + |total, key| match total.checked_add(key.content_size()) { + None => None, + Some(total) if total > max_cache_size as usize => None, + Some(total) => Some(total), + }, + ) + .await?; + if let Some(upper_key) = upper_key { + transaction + .delete_media_by_retention_metadata_to( + upper_key.ignore_policy(), + upper_key.last_access(), + upper_key.content_size(), + ) + .await?; + } + } + } + + transaction.put_media_cleanup_time(current_time).await?; + transaction.commit().await.map_err(Into::into) + }, + ) + .await } #[instrument(skip_all)] @@ -473,8 +530,13 @@ impl MediaStoreInner for IndexeddbMediaStore { ) -> Result, IndexeddbMediaStoreError> { let _timer = timer!("method"); let time = self - .transaction(&[MediaCleanupTime::OBJECT_STORE], TransactionMode::Readonly)? - .get_media_cleanup_time() + .with_transaction( + &[MediaCleanupTime::OBJECT_STORE], + TransactionMode::Readonly, + async move |transaction| { + transaction.get_media_cleanup_time().await.map_err(Into::into) + }, + ) .await?; Ok(time.map(Into::into)) } From a5e300817273dfeafdfa3ebda50712740f073480 Mon Sep 17 00:00:00 2001 From: Daniel Salinas Date: Thu, 18 Jun 2026 19:52:55 +0000 Subject: [PATCH 2/5] fix(indexeddb): reopen state-store connection when the browser closes it Route every state-store transaction (sync token, room infos, room/stripped state, profiles, receipts, account data, send queue, dependent send queue, thread subscriptions, custom values) through `IndexeddbConnection::with_transaction`, and implement `reopen()`. The state store held a long-lived `inner: Database` handle with no-op close/reopen, so a browser-closed connection bricked the room list and the sync token until app restart. The data handle is now an `IndexeddbConnection` that reopens (via `upgrade_inner_db`, using the retained store cipher and meta db) and retries once on a DOM `InvalidStateError`. The `meta` database (migration bookkeeping/backups only) stays a plain handle. --- .../src/state_store/mod.rs | 1767 +++++++++-------- 1 file changed, 940 insertions(+), 827 deletions(-) diff --git a/crates/matrix-sdk-indexeddb/src/state_store/mod.rs b/crates/matrix-sdk-indexeddb/src/state_store/mod.rs index 560fc91bbcf..ae988212c41 100644 --- a/crates/matrix-sdk-indexeddb/src/state_store/mod.rs +++ b/crates/matrix-sdk-indexeddb/src/state_store/mod.rs @@ -23,8 +23,12 @@ use async_trait::async_trait; use gloo_utils::format::JsValueSerdeExt; use growable_bloom_filter::GrowableBloom; use indexed_db_futures::{ - KeyRange, cursor::CursorDirection, database::Database, error::OpenDbError, prelude::*, - transaction::TransactionMode, + KeyRange, + cursor::CursorDirection, + database::Database, + error::OpenDbError, + prelude::*, + transaction::{Transaction, TransactionMode}, }; use matrix_sdk_base::{ MinimalRoomMemberEvent, ROOM_VERSION_FALLBACK, ROOM_VERSION_RULES_FALLBACK, RoomInfo, @@ -64,7 +68,10 @@ mod migrations; pub use self::migrations::MigrationConflictStrategy; use self::migrations::{upgrade_inner_db, upgrade_meta_db}; -use crate::{error::GenericError, serializer::safe_encode::traits::SafeEncode}; +use crate::{ + connection::IndexeddbConnection, error::GenericError, + serializer::safe_encode::traits::SafeEncode, +}; #[derive(Debug, thiserror::Error)] pub enum IndexeddbStateStoreError { @@ -315,13 +322,19 @@ impl IndexeddbStateStoreBuilder { let inner = upgrade_inner_db(&name, store_cipher.as_deref(), migration_strategy, &meta).await?; - Ok(IndexeddbStateStore { name, inner, meta, store_cipher }) + Ok(IndexeddbStateStore { + connection: IndexeddbConnection::new(name, inner), + meta, + store_cipher, + }) } } pub struct IndexeddbStateStore { - name: String, - pub(crate) inner: Database, + // A reopenable handle to the data database. See [`IndexeddbConnection`] for + // why the handle must be reopenable rather than a plain `Database`. + pub(crate) connection: IndexeddbConnection, + // The metadata database, used only for migration bookkeeping and backups. pub(crate) meta: Database, pub(crate) store_cipher: Option>, } @@ -329,7 +342,7 @@ pub struct IndexeddbStateStore { #[cfg(not(tarpaulin_include))] impl std::fmt::Debug for IndexeddbStateStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("IndexeddbStateStore").field("name", &self.name).finish() + f.debug_struct("IndexeddbStateStore").field("name", &self.connection.name()).finish() } } @@ -343,7 +356,40 @@ impl IndexeddbStateStore { /// The version of the database containing the data. pub fn version(&self) -> u32 { - self.inner.version() as u32 + self.connection.database().version() as u32 + } + + /// Run `f` against a fresh transaction over `stores`, reopening the data + /// connection and retrying once if the browser has closed it. + /// + /// Without this, a transient connection loss would permanently brick the + /// state store — room-list and sync-token reads/writes would keep failing + /// until the app is restarted. + async fn with_transaction( + &self, + stores: &[&str], + mode: TransactionMode, + f: F, + ) -> Result + where + F: AsyncFnOnce(Transaction<'_>) -> Result, + { + self.connection + .with_transaction( + stores, + mode, + |name| async move { + upgrade_inner_db( + &name, + self.store_cipher.as_deref(), + MigrationConflictStrategy::Raise, + &self.meta, + ) + .await + }, + f, + ) + .await } /// The version of the database containing the metadata. @@ -417,49 +463,52 @@ impl IndexeddbStateStore { ) -> Result> { let store_name = if stripped { keys::STRIPPED_USER_IDS } else { keys::USER_IDS }; - let tx = self.inner.transaction(store_name).with_mode(TransactionMode::Readonly).build()?; - let store = tx.object_store(store_name)?; - let range = self.encode_to_range(store_name, room_id); - - let user_ids = if memberships.is_empty() { - // It should be faster to just get all user IDs in this case. - store - .get_all() - .with_query(&range) - .await? - .filter_map(Result::ok) - .filter_map(|f| self.deserialize_value::(&f).ok().map(|m| m.user_id)) - .collect::>() - } else { - let mut user_ids = Vec::new(); - let cursor = store.open_cursor().with_query(&range).await?; - - if let Some(mut cursor) = cursor { - while let Some(value) = cursor.next_record().await? { - let member = self.deserialize_value::(&value)?; + self.with_transaction(&[store_name], TransactionMode::Readonly, async move |tx| { + let store = tx.object_store(store_name)?; + let range = self.encode_to_range(store_name, room_id); - if memberships.matches(&member.membership) { - user_ids.push(member.user_id); + let user_ids = if memberships.is_empty() { + // It should be faster to just get all user IDs in this case. + store + .get_all() + .with_query(&range) + .await? + .filter_map(Result::ok) + .filter_map(|f| { + self.deserialize_value::(&f).ok().map(|m| m.user_id) + }) + .collect::>() + } else { + let mut user_ids = Vec::new(); + let cursor = store.open_cursor().with_query(&range).await?; + + if let Some(mut cursor) = cursor { + while let Some(value) = cursor.next_record().await? { + let member = self.deserialize_value::(&value)?; + + if memberships.matches(&member.membership) { + user_ids.push(member.user_id); + } } } - } - user_ids - }; + user_ids + }; - Ok(user_ids) + Ok(user_ids) + }) + .await } async fn get_custom_value_for_js(&self, jskey: &JsValue) -> Result>> { - self.inner - .transaction(keys::CUSTOM) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::CUSTOM)? - .get(jskey) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose() + self.with_transaction(&[keys::CUSTOM], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::CUSTOM)? + .get(jskey) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose() + }) + .await } fn encode_kv_data_key(&self, key: StateStoreDataKey<'_>) -> JsValue { @@ -620,12 +669,9 @@ impl_state_store!({ let encoded_key = self.encode_kv_data_key(key); let value = self - .inner - .transaction(keys::KV) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::KV)? - .get(&encoded_key) + .with_transaction(&[keys::KV], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::KV)?.get(&encoded_key).await.map_err(Into::into) + }) .await?; let value = match key { @@ -735,28 +781,31 @@ impl_state_store!({ ), }; - let tx = self.inner.transaction(keys::KV).with_mode(TransactionMode::Readwrite).build()?; - - let obj = tx.object_store(keys::KV)?; + self.with_transaction(&[keys::KV], TransactionMode::Readwrite, async move |tx| { + let obj = tx.object_store(keys::KV)?; - obj.put(&serialized_value?).with_key(encoded_key).build()?; + obj.put(&serialized_value?).with_key(encoded_key).build()?; - tx.commit().await?; + tx.commit().await?; - Ok(()) + Ok(()) + }) + .await } async fn remove_kv_data(&self, key: StateStoreDataKey<'_>) -> Result<()> { let encoded_key = self.encode_kv_data_key(key); - let tx = self.inner.transaction(keys::KV).with_mode(TransactionMode::Readwrite).build()?; - let obj = tx.object_store(keys::KV)?; + self.with_transaction(&[keys::KV], TransactionMode::Readwrite, async move |tx| { + let obj = tx.object_store(keys::KV)?; - obj.delete(&encoded_key).build()?; + obj.delete(&encoded_key).build()?; - tx.commit().await?; + tx.commit().await?; - Ok(()) + Ok(()) + }) + .await } async fn save_changes(&self, changes: &StateChanges) -> Result<()> { @@ -808,83 +857,84 @@ impl_state_store!({ } let stores: Vec<&'static str> = stores.into_iter().collect(); - let tx = self.inner.transaction(stores).with_mode(TransactionMode::Readwrite).build()?; - - if let Some(s) = &changes.sync_token { - tx.object_store(keys::KV)? - .put(&self.serialize_value(s)?) - .with_key(self.encode_kv_data_key(StateStoreDataKey::SyncToken)) - .build()?; - } - - if !changes.ambiguity_maps.is_empty() { - let store = tx.object_store(keys::DISPLAY_NAMES)?; - for (room_id, ambiguity_maps) in &changes.ambiguity_maps { - for (display_name, map) in ambiguity_maps { - let key = self.encode_key( - keys::DISPLAY_NAMES, - ( - room_id, - display_name - .as_normalized_str() - .unwrap_or_else(|| display_name.as_raw_str()), - ), - ); + self.with_transaction(&stores, TransactionMode::Readwrite, async move |tx| { + if let Some(s) = &changes.sync_token { + tx.object_store(keys::KV)? + .put(&self.serialize_value(s)?) + .with_key(self.encode_kv_data_key(StateStoreDataKey::SyncToken)) + .build()?; + } - store.put(&self.serialize_value(&map)?).with_key(key).build()?; + if !changes.ambiguity_maps.is_empty() { + let store = tx.object_store(keys::DISPLAY_NAMES)?; + for (room_id, ambiguity_maps) in &changes.ambiguity_maps { + for (display_name, map) in ambiguity_maps { + let key = self.encode_key( + keys::DISPLAY_NAMES, + ( + room_id, + display_name + .as_normalized_str() + .unwrap_or_else(|| display_name.as_raw_str()), + ), + ); + + store.put(&self.serialize_value(&map)?).with_key(key).build()?; + } } } - } - if !changes.account_data.is_empty() { - let store = tx.object_store(keys::ACCOUNT_DATA)?; - for (event_type, event) in &changes.account_data { - store - .put(&self.serialize_value(&event)?) - .with_key(self.encode_key(keys::ACCOUNT_DATA, event_type)) - .build()?; + if !changes.account_data.is_empty() { + let store = tx.object_store(keys::ACCOUNT_DATA)?; + for (event_type, event) in &changes.account_data { + store + .put(&self.serialize_value(&event)?) + .with_key(self.encode_key(keys::ACCOUNT_DATA, event_type)) + .build()?; + } } - } - if !changes.room_account_data.is_empty() { - let store = tx.object_store(keys::ROOM_ACCOUNT_DATA)?; - for (room, events) in &changes.room_account_data { - for (event_type, event) in events { - let key = self.encode_key(keys::ROOM_ACCOUNT_DATA, (room, event_type)); - store.put(&self.serialize_value(&event)?).with_key(key).build()?; + if !changes.room_account_data.is_empty() { + let store = tx.object_store(keys::ROOM_ACCOUNT_DATA)?; + for (room, events) in &changes.room_account_data { + for (event_type, event) in events { + let key = self.encode_key(keys::ROOM_ACCOUNT_DATA, (room, event_type)); + store.put(&self.serialize_value(&event)?).with_key(key).build()?; + } } } - } - if !changes.state.is_empty() { - let state = tx.object_store(keys::ROOM_STATE)?; - let profiles = tx.object_store(keys::PROFILES)?; - let user_ids = tx.object_store(keys::USER_IDS)?; - let stripped_state = tx.object_store(keys::STRIPPED_ROOM_STATE)?; - let stripped_user_ids = tx.object_store(keys::STRIPPED_USER_IDS)?; - - for (room, user_ids) in &changes.profiles_to_delete { - for user_id in user_ids { - let key = self.encode_key(keys::PROFILES, (room, user_id)); - profiles.delete(&key).build()?; + if !changes.state.is_empty() { + let state = tx.object_store(keys::ROOM_STATE)?; + let profiles = tx.object_store(keys::PROFILES)?; + let user_ids = tx.object_store(keys::USER_IDS)?; + let stripped_state = tx.object_store(keys::STRIPPED_ROOM_STATE)?; + let stripped_user_ids = tx.object_store(keys::STRIPPED_USER_IDS)?; + + for (room, user_ids) in &changes.profiles_to_delete { + for user_id in user_ids { + let key = self.encode_key(keys::PROFILES, (room, user_id)); + profiles.delete(&key).build()?; + } } - } - for (room, event_types) in &changes.state { - let profile_changes = changes.profiles.get(room); - - for (event_type, events) in event_types { - for (state_key, raw_event) in events { - let key = self.encode_key(keys::ROOM_STATE, (room, event_type, state_key)); - state - .put(&self.serialize_value(&raw_event)?) - .with_key(key.clone()) - .build()?; - stripped_state.delete(&key).build()?; - - if *event_type == StateEventType::RoomMember { - let event = - match raw_event.deserialize_as_unchecked::() { + for (room, event_types) in &changes.state { + let profile_changes = changes.profiles.get(room); + + for (event_type, events) in event_types { + for (state_key, raw_event) in events { + let key = + self.encode_key(keys::ROOM_STATE, (room, event_type, state_key)); + state + .put(&self.serialize_value(&raw_event)?) + .with_key(key.clone()) + .build()?; + stripped_state.delete(&key).build()?; + + if *event_type == StateEventType::RoomMember { + let event = match raw_event + .deserialize_as_unchecked::() + { Ok(ev) => ev, Err(e) => { let event_id: Option = @@ -894,221 +944,224 @@ impl_state_store!({ } }; - let key = (room, state_key); + let key = (room, state_key); - stripped_user_ids - .delete(&self.encode_key(keys::STRIPPED_USER_IDS, key)) - .build()?; - - user_ids - .put(&self.serialize_value(&RoomMember::from(&event))?) - .with_key(self.encode_key(keys::USER_IDS, key)) - .build()?; + stripped_user_ids + .delete(&self.encode_key(keys::STRIPPED_USER_IDS, key)) + .build()?; - if let Some(profile) = - profile_changes.and_then(|p| p.get(event.state_key())) - { - profiles - .put(&self.serialize_value(&profile)?) - .with_key(self.encode_key(keys::PROFILES, key)) + user_ids + .put(&self.serialize_value(&RoomMember::from(&event))?) + .with_key(self.encode_key(keys::USER_IDS, key)) .build()?; + + if let Some(profile) = + profile_changes.and_then(|p| p.get(event.state_key())) + { + profiles + .put(&self.serialize_value(&profile)?) + .with_key(self.encode_key(keys::PROFILES, key)) + .build()?; + } } } } } } - } - if !changes.room_infos.is_empty() { - let room_infos = tx.object_store(keys::ROOM_INFOS)?; - for (room_id, room_info) in &changes.room_infos { - room_infos - .put(&self.serialize_value(&room_info)?) - .with_key(self.encode_key(keys::ROOM_INFOS, room_id)) - .build()?; + if !changes.room_infos.is_empty() { + let room_infos = tx.object_store(keys::ROOM_INFOS)?; + for (room_id, room_info) in &changes.room_infos { + room_infos + .put(&self.serialize_value(&room_info)?) + .with_key(self.encode_key(keys::ROOM_INFOS, room_id)) + .build()?; + } } - } - if !changes.presence.is_empty() { - let store = tx.object_store(keys::PRESENCE)?; - for (sender, event) in &changes.presence { - store - .put(&self.serialize_value(&event)?) - .with_key(self.encode_key(keys::PRESENCE, sender)) - .build()?; + if !changes.presence.is_empty() { + let store = tx.object_store(keys::PRESENCE)?; + for (sender, event) in &changes.presence { + store + .put(&self.serialize_value(&event)?) + .with_key(self.encode_key(keys::PRESENCE, sender)) + .build()?; + } } - } - if !changes.stripped_state.is_empty() { - let store = tx.object_store(keys::STRIPPED_ROOM_STATE)?; - let user_ids = tx.object_store(keys::STRIPPED_USER_IDS)?; - - for (room, event_types) in &changes.stripped_state { - for (event_type, events) in event_types { - for (state_key, raw_event) in events { - let key = self - .encode_key(keys::STRIPPED_ROOM_STATE, (room, event_type, state_key)); - store.put(&self.serialize_value(&raw_event)?).with_key(key).build()?; - - if *event_type == StateEventType::RoomMember { - let event = match raw_event - .deserialize_as_unchecked::() - { - Ok(ev) => ev, - Err(e) => { - let event_id: Option = - raw_event.get_field("event_id").ok().flatten(); - debug!( - event_id, - "Failed to deserialize stripped member event: {e}" - ); - continue; - } - }; + if !changes.stripped_state.is_empty() { + let store = tx.object_store(keys::STRIPPED_ROOM_STATE)?; + let user_ids = tx.object_store(keys::STRIPPED_USER_IDS)?; + + for (room, event_types) in &changes.stripped_state { + for (event_type, events) in event_types { + for (state_key, raw_event) in events { + let key = self.encode_key( + keys::STRIPPED_ROOM_STATE, + (room, event_type, state_key), + ); + store.put(&self.serialize_value(&raw_event)?).with_key(key).build()?; + + if *event_type == StateEventType::RoomMember { + let event = match raw_event + .deserialize_as_unchecked::() + { + Ok(ev) => ev, + Err(e) => { + let event_id: Option = + raw_event.get_field("event_id").ok().flatten(); + debug!( + event_id, + "Failed to deserialize stripped member event: {e}" + ); + continue; + } + }; - let key = (room, state_key); + let key = (room, state_key); - user_ids - .put(&self.serialize_value(&RoomMember::from(&event))?) - .with_key(self.encode_key(keys::STRIPPED_USER_IDS, key)) - .build()?; + user_ids + .put(&self.serialize_value(&RoomMember::from(&event))?) + .with_key(self.encode_key(keys::STRIPPED_USER_IDS, key)) + .build()?; + } } } } } - } - if !changes.receipts.is_empty() { - let room_user_receipts = tx.object_store(keys::ROOM_USER_RECEIPTS)?; - let room_event_receipts = tx.object_store(keys::ROOM_EVENT_RECEIPTS)?; - - for (room, content) in &changes.receipts { - for (event_id, receipts) in &content.0 { - for (receipt_type, receipts) in receipts { - for (user_id, receipt) in receipts { - let key = match receipt.thread.as_str() { - Some(thread_id) => self.encode_key( - keys::ROOM_USER_RECEIPTS, - (room, receipt_type, thread_id, user_id), - ), - None => self.encode_key( - keys::ROOM_USER_RECEIPTS, - (room, receipt_type, user_id), - ), - }; + if !changes.receipts.is_empty() { + let room_user_receipts = tx.object_store(keys::ROOM_USER_RECEIPTS)?; + let room_event_receipts = tx.object_store(keys::ROOM_EVENT_RECEIPTS)?; - if let Some((old_event, _)) = - room_user_receipts.get(&key).await?.and_then(|f| { - self.deserialize_value::<(OwnedEventId, Receipt)>(&f).ok() - }) - { + for (room, content) in &changes.receipts { + for (event_id, receipts) in &content.0 { + for (receipt_type, receipts) in receipts { + for (user_id, receipt) in receipts { + let key = match receipt.thread.as_str() { + Some(thread_id) => self.encode_key( + keys::ROOM_USER_RECEIPTS, + (room, receipt_type, thread_id, user_id), + ), + None => self.encode_key( + keys::ROOM_USER_RECEIPTS, + (room, receipt_type, user_id), + ), + }; + + if let Some((old_event, _)) = + room_user_receipts.get(&key).await?.and_then(|f| { + self.deserialize_value::<(OwnedEventId, Receipt)>(&f).ok() + }) + { + let key = match receipt.thread.as_str() { + Some(thread_id) => self.encode_key( + keys::ROOM_EVENT_RECEIPTS, + (room, receipt_type, thread_id, old_event, user_id), + ), + None => self.encode_key( + keys::ROOM_EVENT_RECEIPTS, + (room, receipt_type, old_event, user_id), + ), + }; + room_event_receipts.delete(&key).build()?; + } + + room_user_receipts + .put(&self.serialize_value(&(event_id, receipt))?) + .with_key(key) + .build()?; + + // Add the receipt to the room event receipts let key = match receipt.thread.as_str() { Some(thread_id) => self.encode_key( keys::ROOM_EVENT_RECEIPTS, - (room, receipt_type, thread_id, old_event, user_id), + (room, receipt_type, thread_id, event_id, user_id), ), None => self.encode_key( keys::ROOM_EVENT_RECEIPTS, - (room, receipt_type, old_event, user_id), + (room, receipt_type, event_id, user_id), ), }; - room_event_receipts.delete(&key).build()?; + room_event_receipts + .put(&self.serialize_value(&(user_id, receipt))?) + .with_key(key) + .build()?; } - - room_user_receipts - .put(&self.serialize_value(&(event_id, receipt))?) - .with_key(key) - .build()?; - - // Add the receipt to the room event receipts - let key = match receipt.thread.as_str() { - Some(thread_id) => self.encode_key( - keys::ROOM_EVENT_RECEIPTS, - (room, receipt_type, thread_id, event_id, user_id), - ), - None => self.encode_key( - keys::ROOM_EVENT_RECEIPTS, - (room, receipt_type, event_id, user_id), - ), - }; - room_event_receipts - .put(&self.serialize_value(&(user_id, receipt))?) - .with_key(key) - .build()?; } } } } - } - if !changes.redactions.is_empty() { - let state = tx.object_store(keys::ROOM_STATE)?; - let room_info = tx.object_store(keys::ROOM_INFOS)?; + if !changes.redactions.is_empty() { + let state = tx.object_store(keys::ROOM_STATE)?; + let room_info = tx.object_store(keys::ROOM_INFOS)?; - for (room_id, redactions) in &changes.redactions { - let range = self.encode_to_range(keys::ROOM_STATE, room_id); - let Some(mut cursor) = state.open_cursor().with_query(&range).await? else { - continue; - }; + for (room_id, redactions) in &changes.redactions { + let range = self.encode_to_range(keys::ROOM_STATE, room_id); + let Some(mut cursor) = state.open_cursor().with_query(&range).await? else { + continue; + }; - let mut redaction_rules = None; + let mut redaction_rules = None; - while let Some(value) = cursor.next_record().await? { - let Some(key) = cursor.key::()? else { - break; - }; + while let Some(value) = cursor.next_record().await? { + let Some(key) = cursor.key::()? else { + break; + }; - let raw_evt = self.deserialize_value::>(&value)?; - if let Ok(Some(event_id)) = raw_evt.get_field::("event_id") - && let Some(redaction) = redactions.get(&event_id) - { - let redaction_rules = match &redaction_rules { - Some(r) => r, - None => { - let value = room_info - .get(&self.encode_key(keys::ROOM_INFOS, room_id)) - .await? - .and_then(|f| self.deserialize_value::(&f).ok()) - .map(|info| info.room_version_rules_or_default()) - .unwrap_or_else(|| { - warn!( - ?room_id, - "Unable to get the room version rules, \ + let raw_evt = self.deserialize_value::>(&value)?; + if let Ok(Some(event_id)) = raw_evt.get_field::("event_id") + && let Some(redaction) = redactions.get(&event_id) + { + let redaction_rules = match &redaction_rules { + Some(r) => r, + None => { + let value = room_info + .get(&self.encode_key(keys::ROOM_INFOS, room_id)) + .await? + .and_then(|f| self.deserialize_value::(&f).ok()) + .map(|info| info.room_version_rules_or_default()) + .unwrap_or_else(|| { + warn!( + ?room_id, + "Unable to get the room version rules, \ defaulting to rules for room version \ {ROOM_VERSION_FALLBACK}" - ); - ROOM_VERSION_RULES_FALLBACK - }) - .redaction; - redaction_rules.get_or_insert(value) - } - }; + ); + ROOM_VERSION_RULES_FALLBACK + }) + .redaction; + redaction_rules.get_or_insert(value) + } + }; - let redacted = redact( - raw_evt.deserialize_as::()?, - redaction_rules, - Some(RedactedBecause::from_raw_event(redaction)?), - ) - .map_err(StoreError::Redaction)?; - state.put(&self.serialize_value(&redacted)?).with_key(key).build()?; + let redacted = redact( + raw_evt.deserialize_as::()?, + redaction_rules, + Some(RedactedBecause::from_raw_event(redaction)?), + ) + .map_err(StoreError::Redaction)?; + state.put(&self.serialize_value(&redacted)?).with_key(key).build()?; + } } } } - } - tx.commit().await.map_err(|e| e.into()) + tx.commit().await.map_err(|e| e.into()) + }) + .await } async fn get_presence_event(&self, user_id: &UserId) -> Result>> { - self.inner - .transaction(keys::PRESENCE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::PRESENCE)? - .get(&self.encode_key(keys::PRESENCE, user_id)) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose() + self.with_transaction(&[keys::PRESENCE], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::PRESENCE)? + .get(&self.encode_key(keys::PRESENCE, user_id)) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose() + }) + .await } async fn get_presence_events( @@ -1119,24 +1172,25 @@ impl_state_store!({ return Ok(Vec::new()); } - let txn = - self.inner.transaction(keys::PRESENCE).with_mode(TransactionMode::Readonly).build()?; - let store = txn.object_store(keys::PRESENCE)?; + self.with_transaction(&[keys::PRESENCE], TransactionMode::Readonly, async move |txn| { + let store = txn.object_store(keys::PRESENCE)?; - let mut events = Vec::with_capacity(user_ids.len()); + let mut events = Vec::with_capacity(user_ids.len()); - for user_id in user_ids { - if let Some(event) = store - .get(&self.encode_key(keys::PRESENCE, user_id)) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose()? - { - events.push(event) + for user_id in user_ids { + if let Some(event) = store + .get(&self.encode_key(keys::PRESENCE, user_id)) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose()? + { + events.push(event) + } } - } - Ok(events) + Ok(events) + }) + .await } async fn get_state_event( @@ -1160,37 +1214,42 @@ impl_state_store!({ let stripped_range = self.encode_to_range(keys::STRIPPED_ROOM_STATE, (room_id, &event_type)); let stripped_events = self - .inner - .transaction(keys::STRIPPED_ROOM_STATE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::STRIPPED_ROOM_STATE)? - .get_all() - .with_query(&stripped_range) - .await? - .filter_map(Result::ok) - .filter_map(|f| { - self.deserialize_value(&f).ok().map(RawAnySyncOrStrippedState::Stripped) - }) - .collect::>(); + .with_transaction( + &[keys::STRIPPED_ROOM_STATE], + TransactionMode::Readonly, + async move |tx| { + Ok(tx + .object_store(keys::STRIPPED_ROOM_STATE)? + .get_all() + .with_query(&stripped_range) + .await? + .filter_map(Result::ok) + .filter_map(|f| { + self.deserialize_value(&f).ok().map(RawAnySyncOrStrippedState::Stripped) + }) + .collect::>()) + }, + ) + .await?; if !stripped_events.is_empty() { return Ok(stripped_events); } let range = self.encode_to_range(keys::ROOM_STATE, (room_id, event_type)); - Ok(self - .inner - .transaction(keys::ROOM_STATE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::ROOM_STATE)? - .get_all() - .with_query(&range) - .await? - .filter_map(Result::ok) - .filter_map(|f| self.deserialize_value(&f).ok().map(RawAnySyncOrStrippedState::Sync)) - .collect::>()) + self.with_transaction(&[keys::ROOM_STATE], TransactionMode::Readonly, async move |tx| { + Ok(tx + .object_store(keys::ROOM_STATE)? + .get_all() + .with_query(&range) + .await? + .filter_map(Result::ok) + .filter_map(|f| { + self.deserialize_value(&f).ok().map(RawAnySyncOrStrippedState::Sync) + }) + .collect::>()) + }) + .await } async fn get_state_events_for_keys( @@ -1203,55 +1262,53 @@ impl_state_store!({ return Ok(Vec::new()); } - let mut events = Vec::with_capacity(state_keys.len()); + let stripped = self + .with_transaction( + &[keys::STRIPPED_ROOM_STATE], + TransactionMode::Readonly, + async |txn| { + let store = txn.object_store(keys::STRIPPED_ROOM_STATE)?; + + let mut events = Vec::with_capacity(state_keys.len()); + for state_key in state_keys { + if let Some(event) = store + .get(&self.encode_key( + keys::STRIPPED_ROOM_STATE, + (room_id, &event_type, state_key), + )) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose()? + { + events.push(RawAnySyncOrStrippedState::Stripped(event)); + } + } + Ok(events) + }, + ) + .await?; + + if !stripped.is_empty() { + return Ok(stripped); + } - { - let txn = self - .inner - .transaction(keys::STRIPPED_ROOM_STATE) - .with_mode(TransactionMode::Readonly) - .build()?; - let store = txn.object_store(keys::STRIPPED_ROOM_STATE)?; + self.with_transaction(&[keys::ROOM_STATE], TransactionMode::Readonly, async |txn| { + let store = txn.object_store(keys::ROOM_STATE)?; + let mut events = Vec::with_capacity(state_keys.len()); for state_key in state_keys { - if let Some(event) = - store - .get(&self.encode_key( - keys::STRIPPED_ROOM_STATE, - (room_id, &event_type, state_key), - )) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose()? + if let Some(event) = store + .get(&self.encode_key(keys::ROOM_STATE, (room_id, &event_type, state_key))) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose()? { - events.push(RawAnySyncOrStrippedState::Stripped(event)); + events.push(RawAnySyncOrStrippedState::Sync(event)); } } - - if !events.is_empty() { - return Ok(events); - } - } - - let txn = self - .inner - .transaction(keys::ROOM_STATE) - .with_mode(TransactionMode::Readonly) - .build()?; - let store = txn.object_store(keys::ROOM_STATE)?; - - for state_key in state_keys { - if let Some(event) = store - .get(&self.encode_key(keys::ROOM_STATE, (room_id, &event_type, state_key))) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose()? - { - events.push(RawAnySyncOrStrippedState::Sync(event)); - } - } - - Ok(events) + Ok(events) + }) + .await } async fn get_profile( @@ -1259,15 +1316,14 @@ impl_state_store!({ room_id: &RoomId, user_id: &UserId, ) -> Result> { - self.inner - .transaction(keys::PROFILES) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::PROFILES)? - .get(&self.encode_key(keys::PROFILES, (room_id, user_id))) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose() + self.with_transaction(&[keys::PROFILES], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::PROFILES)? + .get(&self.encode_key(keys::PROFILES, (room_id, user_id))) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose() + }) + .await } async fn get_profiles<'a>( @@ -1279,48 +1335,52 @@ impl_state_store!({ return Ok(BTreeMap::new()); } - let txn = - self.inner.transaction(keys::PROFILES).with_mode(TransactionMode::Readonly).build()?; - let store = txn.object_store(keys::PROFILES)?; + self.with_transaction(&[keys::PROFILES], TransactionMode::Readonly, async move |txn| { + let store = txn.object_store(keys::PROFILES)?; - let mut profiles = BTreeMap::new(); - for user_id in user_ids { - if let Some(profile) = store - .get(&self.encode_key(keys::PROFILES, (room_id, user_id))) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose()? - { - profiles.insert(user_id.as_ref(), profile); + let mut profiles = BTreeMap::new(); + for user_id in user_ids { + if let Some(profile) = store + .get(&self.encode_key(keys::PROFILES, (room_id, user_id))) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose()? + { + profiles.insert(user_id.as_ref(), profile); + } } - } - Ok(profiles) + Ok(profiles) + }) + .await } async fn get_room_infos(&self, room_load_settings: &RoomLoadSettings) -> Result> { - let transaction = self - .inner - .transaction(keys::ROOM_INFOS) - .with_mode(TransactionMode::Readonly) - .build()?; - - let object_store = transaction.object_store(keys::ROOM_INFOS)?; - - Ok(match room_load_settings { - RoomLoadSettings::All => object_store - .get_all() - .await? - .map(|room_info| self.deserialize_value::(&room_info?)) - .collect::>()?, + self.with_transaction( + &[keys::ROOM_INFOS], + TransactionMode::Readonly, + async move |transaction| { + let object_store = transaction.object_store(keys::ROOM_INFOS)?; + + Ok(match room_load_settings { + RoomLoadSettings::All => object_store + .get_all() + .await? + .map(|room_info| self.deserialize_value::(&room_info?)) + .collect::>()?, - RoomLoadSettings::One(room_id) => { - match object_store.get(&self.encode_key(keys::ROOM_INFOS, room_id)).await? { - Some(room_info) => vec![self.deserialize_value::(&room_info)?], - None => vec![], - } - } - }) + RoomLoadSettings::One(room_id) => { + match object_store.get(&self.encode_key(keys::ROOM_INFOS, room_id)).await? { + Some(room_info) => { + vec![self.deserialize_value::(&room_info)?] + } + None => vec![], + } + } + }) + }, + ) + .await } async fn get_users_with_display_name( @@ -1328,21 +1388,24 @@ impl_state_store!({ room_id: &RoomId, display_name: &DisplayName, ) -> Result> { - self.inner - .transaction(keys::DISPLAY_NAMES) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::DISPLAY_NAMES)? - .get(&self.encode_key( - keys::DISPLAY_NAMES, - ( - room_id, - display_name.as_normalized_str().unwrap_or_else(|| display_name.as_raw_str()), - ), - )) - .await? - .map(|f| self.deserialize_value::>(&f)) - .unwrap_or_else(|| Ok(Default::default())) + self.with_transaction(&[keys::DISPLAY_NAMES], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::DISPLAY_NAMES)? + .get( + &self.encode_key( + keys::DISPLAY_NAMES, + ( + room_id, + display_name + .as_normalized_str() + .unwrap_or_else(|| display_name.as_raw_str()), + ), + ), + ) + .await? + .map(|f| self.deserialize_value::>(&f)) + .unwrap_or_else(|| Ok(Default::default())) + }) + .await } async fn get_users_with_display_names<'a>( @@ -1356,50 +1419,47 @@ impl_state_store!({ return Ok(map); } - let txn = self - .inner - .transaction(keys::DISPLAY_NAMES) - .with_mode(TransactionMode::Readonly) - .build()?; - let store = txn.object_store(keys::DISPLAY_NAMES)?; - - for display_name in display_names { - if let Some(user_ids) = store - .get( - &self.encode_key( - keys::DISPLAY_NAMES, - ( - room_id, - display_name - .as_normalized_str() - .unwrap_or_else(|| display_name.as_raw_str()), + self.with_transaction(&[keys::DISPLAY_NAMES], TransactionMode::Readonly, async move |txn| { + let store = txn.object_store(keys::DISPLAY_NAMES)?; + + for display_name in display_names { + if let Some(user_ids) = store + .get( + &self.encode_key( + keys::DISPLAY_NAMES, + ( + room_id, + display_name + .as_normalized_str() + .unwrap_or_else(|| display_name.as_raw_str()), + ), ), - ), - ) - .await? - .map(|f| self.deserialize_value::>(&f)) - .transpose()? - { - map.insert(display_name, user_ids); + ) + .await? + .map(|f| self.deserialize_value::>(&f)) + .transpose()? + { + map.insert(display_name, user_ids); + } } - } - Ok(map) + Ok(map) + }) + .await } async fn get_account_data_event( &self, event_type: GlobalAccountDataEventType, ) -> Result>> { - self.inner - .transaction(keys::ACCOUNT_DATA) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::ACCOUNT_DATA)? - .get(&self.encode_key(keys::ACCOUNT_DATA, event_type)) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose() + self.with_transaction(&[keys::ACCOUNT_DATA], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::ACCOUNT_DATA)? + .get(&self.encode_key(keys::ACCOUNT_DATA, event_type)) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose() + }) + .await } async fn get_room_account_data_event( @@ -1407,15 +1467,18 @@ impl_state_store!({ room_id: &RoomId, event_type: RoomAccountDataEventType, ) -> Result>> { - self.inner - .transaction(keys::ROOM_ACCOUNT_DATA) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::ROOM_ACCOUNT_DATA)? - .get(&self.encode_key(keys::ROOM_ACCOUNT_DATA, (room_id, event_type))) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose() + self.with_transaction( + &[keys::ROOM_ACCOUNT_DATA], + TransactionMode::Readonly, + async move |tx| { + tx.object_store(keys::ROOM_ACCOUNT_DATA)? + .get(&self.encode_key(keys::ROOM_ACCOUNT_DATA, (room_id, event_type))) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose() + }, + ) + .await } async fn get_user_room_receipt_event( @@ -1430,15 +1493,18 @@ impl_state_store!({ .encode_key(keys::ROOM_USER_RECEIPTS, (room_id, receipt_type, thread_id, user_id)), None => self.encode_key(keys::ROOM_USER_RECEIPTS, (room_id, receipt_type, user_id)), }; - self.inner - .transaction(keys::ROOM_USER_RECEIPTS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::ROOM_USER_RECEIPTS)? - .get(&key) - .await? - .map(|f| self.deserialize_value(&f)) - .transpose() + self.with_transaction( + &[keys::ROOM_USER_RECEIPTS], + TransactionMode::Readonly, + async move |tx| { + tx.object_store(keys::ROOM_USER_RECEIPTS)? + .get(&key) + .await? + .map(|f| self.deserialize_value(&f)) + .transpose() + }, + ) + .await } async fn get_event_room_receipt_events( @@ -1457,20 +1523,22 @@ impl_state_store!({ self.encode_to_range(keys::ROOM_EVENT_RECEIPTS, (room_id, receipt_type, event_id)) } }; - let tx = self - .inner - .transaction(keys::ROOM_EVENT_RECEIPTS) - .with_mode(TransactionMode::Readonly) - .build()?; - let store = tx.object_store(keys::ROOM_EVENT_RECEIPTS)?; - - Ok(store - .get_all() - .with_query(&range) - .await? - .filter_map(Result::ok) - .filter_map(|f| self.deserialize_value(&f).ok()) - .collect::>()) + self.with_transaction( + &[keys::ROOM_EVENT_RECEIPTS], + TransactionMode::Readonly, + async move |tx| { + let store = tx.object_store(keys::ROOM_EVENT_RECEIPTS)?; + + Ok(store + .get_all() + .with_query(&range) + .await? + .filter_map(Result::ok) + .filter_map(|f| self.deserialize_value(&f).ok()) + .collect::>()) + }, + ) + .await } async fn get_custom_value(&self, key: &[u8]) -> Result>> { @@ -1483,16 +1551,16 @@ impl_state_store!({ let prev = self.get_custom_value_for_js(&jskey).await?; - let tx = - self.inner.transaction(keys::CUSTOM).with_mode(TransactionMode::Readwrite).build()?; - - tx.object_store(keys::CUSTOM)? - .put(&self.serialize_value(&value)?) - .with_key(jskey) - .build()?; + self.with_transaction(&[keys::CUSTOM], TransactionMode::Readwrite, async move |tx| { + tx.object_store(keys::CUSTOM)? + .put(&self.serialize_value(&value)?) + .with_key(jskey) + .build()?; - tx.commit().await.map_err(IndexeddbStateStoreError::from)?; - Ok(prev) + tx.commit().await.map_err(IndexeddbStateStoreError::from)?; + Ok(prev) + }) + .await } async fn remove_custom_value(&self, key: &[u8]) -> Result>> { @@ -1500,13 +1568,13 @@ impl_state_store!({ let prev = self.get_custom_value_for_js(&jskey).await?; - let tx = - self.inner.transaction(keys::CUSTOM).with_mode(TransactionMode::Readwrite).build()?; + self.with_transaction(&[keys::CUSTOM], TransactionMode::Readwrite, async move |tx| { + tx.object_store(keys::CUSTOM)?.delete(&jskey).build()?; - tx.object_store(keys::CUSTOM)?.delete(&jskey).build()?; - - tx.commit().await.map_err(IndexeddbStateStoreError::from)?; - Ok(prev) + tx.commit().await.map_err(IndexeddbStateStoreError::from)?; + Ok(prev) + }) + .await } async fn remove_room(&self, room_id: &RoomId) -> Result<()> { @@ -1535,22 +1603,24 @@ impl_state_store!({ v }; - let tx = - self.inner.transaction(all_stores).with_mode(TransactionMode::Readwrite).build()?; - - for store_name in direct_stores { - tx.object_store(store_name)?.delete(&self.encode_key(store_name, room_id)).build()?; - } + self.with_transaction(&all_stores, TransactionMode::Readwrite, async move |tx| { + for store_name in direct_stores { + tx.object_store(store_name)? + .delete(&self.encode_key(store_name, room_id)) + .build()?; + } - for store_name in prefixed_stores { - let store = tx.object_store(store_name)?; - let range = self.encode_to_range(store_name, room_id); - for key in store.get_all_keys::().with_query(&range).await? { - store.delete(&key?).build()?; + for store_name in prefixed_stores { + let store = tx.object_store(store_name)?; + let range = self.encode_to_range(store_name, room_id); + for key in store.get_all_keys::().with_query(&range).await? { + store.delete(&key?).build()?; + } } - } - tx.commit().await.map_err(|e| e.into()) + tx.commit().await.map_err(|e| e.into()) + }) + .await } async fn get_user_ids( @@ -1575,43 +1645,44 @@ impl_state_store!({ ) -> Result<()> { let encoded_key = self.encode_key(keys::ROOM_SEND_QUEUE, room_id); - let tx = self - .inner - .transaction(keys::ROOM_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()?; - - let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; - - // We store an encoded vector of the queued requests, with their transaction - // ids. - - // Reload the previous vector for this room, or create an empty one. - let prev = obj.get(&encoded_key).await?; - - let mut prev = prev.map_or_else( - || Ok(Vec::new()), - |val| self.deserialize_value::>(&val), - )?; - - // Push the new request. - prev.push(PersistedQueuedRequest { - room_id: room_id.to_owned(), - kind: Some(kind), - transaction_id, - error: None, - is_wedged: None, - event: None, - priority: Some(priority), - created_at, - }); - - // Save the new vector into db. - obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + self.with_transaction( + &[keys::ROOM_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; + + // We store an encoded vector of the queued requests, with their transaction + // ids. + + // Reload the previous vector for this room, or create an empty one. + let prev = obj.get(&encoded_key).await?; + + let mut prev = prev.map_or_else( + || Ok(Vec::new()), + |val| self.deserialize_value::>(&val), + )?; + + // Push the new request. + prev.push(PersistedQueuedRequest { + room_id: room_id.to_owned(), + kind: Some(kind), + transaction_id, + error: None, + is_wedged: None, + event: None, + priority: Some(priority), + created_at, + }); + + // Save the new vector into db. + obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; - tx.commit().await?; + tx.commit().await?; - Ok(()) + Ok(()) + }, + ) + .await } async fn update_send_queue_request( @@ -1622,42 +1693,45 @@ impl_state_store!({ ) -> Result { let encoded_key = self.encode_key(keys::ROOM_SEND_QUEUE, room_id); - let tx = self - .inner - .transaction(keys::ROOM_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()?; + self.with_transaction( + &[keys::ROOM_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; - let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; + // We store an encoded vector of the queued requests, with their transaction + // ids. - // We store an encoded vector of the queued requests, with their transaction - // ids. - - // Reload the previous vector for this room, or create an empty one. - let prev = obj.get(&encoded_key).await?; + // Reload the previous vector for this room, or create an empty one. + let prev = obj.get(&encoded_key).await?; - let mut prev = prev.map_or_else( - || Ok(Vec::new()), - |val| self.deserialize_value::>(&val), - )?; + let mut prev = prev.map_or_else( + || Ok(Vec::new()), + |val| self.deserialize_value::>(&val), + )?; - // Modify the one request. - if let Some(entry) = prev.iter_mut().find(|entry| entry.transaction_id == transaction_id) { - entry.kind = Some(kind); - // Reset the error state. - entry.error = None; - // Remove migrated fields. - entry.is_wedged = None; - entry.event = None; - - // Save the new vector into db. - obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; - tx.commit().await?; + // Modify the one request. + if let Some(entry) = + prev.iter_mut().find(|entry| entry.transaction_id == transaction_id) + { + entry.kind = Some(kind); + // Reset the error state. + entry.error = None; + // Remove migrated fields. + entry.is_wedged = None; + entry.event = None; + + // Save the new vector into db. + obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + tx.commit().await?; - Ok(true) - } else { - Ok(false) - } + Ok(true) + } else { + Ok(false) + } + }, + ) + .await } async fn remove_send_queue_request( @@ -1667,35 +1741,38 @@ impl_state_store!({ ) -> Result { let encoded_key = self.encode_key(keys::ROOM_SEND_QUEUE, room_id); - let tx = self - .inner - .transaction([keys::ROOM_SEND_QUEUE, keys::DEPENDENT_SEND_QUEUE]) - .with_mode(TransactionMode::Readwrite) - .build()?; + self.with_transaction( + &[keys::ROOM_SEND_QUEUE, keys::DEPENDENT_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; - let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; + // We store an encoded vector of the queued requests, with their transaction + // ids. - // We store an encoded vector of the queued requests, with their transaction - // ids. + // Reload the previous vector for this room. + if let Some(val) = obj.get(&encoded_key).await? { + let mut prev = self.deserialize_value::>(&val)?; + if let Some(pos) = + prev.iter().position(|item| item.transaction_id == transaction_id) + { + prev.remove(pos); - // Reload the previous vector for this room. - if let Some(val) = obj.get(&encoded_key).await? { - let mut prev = self.deserialize_value::>(&val)?; - if let Some(pos) = prev.iter().position(|item| item.transaction_id == transaction_id) { - prev.remove(pos); + if prev.is_empty() { + obj.delete(&encoded_key).build()?; + } else { + obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + } - if prev.is_empty() { - obj.delete(&encoded_key).build()?; - } else { - obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + tx.commit().await?; + return Ok(true); + } } - tx.commit().await?; - return Ok(true); - } - } - - Ok(false) + Ok(false) + }, + ) + .await } async fn load_send_queue_requests(&self, room_id: &RoomId) -> Result> { @@ -1704,12 +1781,16 @@ impl_state_store!({ // We store an encoded vector of the queued requests, with their transaction // ids. let prev = self - .inner - .transaction(keys::ROOM_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()? - .object_store(keys::ROOM_SEND_QUEUE)? - .get(&encoded_key) + .with_transaction( + &[keys::ROOM_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + tx.object_store(keys::ROOM_SEND_QUEUE)? + .get(&encoded_key) + .await + .map_err(Into::into) + }, + ) .await?; let mut prev = prev.map_or_else( @@ -1731,49 +1812,51 @@ impl_state_store!({ ) -> Result<()> { let encoded_key = self.encode_key(keys::ROOM_SEND_QUEUE, room_id); - let tx = self - .inner - .transaction(keys::ROOM_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()?; - - let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; - - if let Some(val) = obj.get(&encoded_key).await? { - let mut prev = self.deserialize_value::>(&val)?; - if let Some(request) = - prev.iter_mut().find(|item| item.transaction_id == transaction_id) - { - request.is_wedged = None; - request.error = error; - obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; - } - } + self.with_transaction( + &[keys::ROOM_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; - tx.commit().await?; + if let Some(val) = obj.get(&encoded_key).await? { + let mut prev = self.deserialize_value::>(&val)?; + if let Some(request) = + prev.iter_mut().find(|item| item.transaction_id == transaction_id) + { + request.is_wedged = None; + request.error = error; + obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + } + } - Ok(()) + tx.commit().await?; + + Ok(()) + }, + ) + .await } async fn load_rooms_with_unsent_requests(&self) -> Result> { - let tx = self - .inner - .transaction(keys::ROOM_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()?; - - let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; - - let all_entries = obj - .get_all() - .await? - .map(|item| self.deserialize_value::>(&item?)) - .collect::>, _>>()? - .into_iter() - .flat_map(|vec| vec.into_iter().map(|item| item.room_id)) - .collect::>(); - - Ok(all_entries.into_iter().collect()) + self.with_transaction( + &[keys::ROOM_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::ROOM_SEND_QUEUE)?; + + let all_entries = obj + .get_all() + .await? + .map(|item| self.deserialize_value::>(&item?)) + .collect::>, _>>()? + .into_iter() + .flat_map(|vec| vec.into_iter().map(|item| item.room_id)) + .collect::>(); + + Ok(all_entries.into_iter().collect()) + }, + ) + .await } async fn save_dependent_queued_request( @@ -1786,38 +1869,39 @@ impl_state_store!({ ) -> Result<()> { let encoded_key = self.encode_key(keys::DEPENDENT_SEND_QUEUE, room_id); - let tx = self - .inner - .transaction(keys::DEPENDENT_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()?; - - let obj = tx.object_store(keys::DEPENDENT_SEND_QUEUE)?; - - // We store an encoded vector of the dependent requests. - // Reload the previous vector for this room, or create an empty one. - let prev = obj.get(&encoded_key).await?; - - let mut prev = prev.map_or_else( - || Ok(Vec::new()), - |val| self.deserialize_value::>(&val), - )?; - - // Push the new request. - prev.push(DependentQueuedRequest { - kind: content, - parent_transaction_id: parent_txn_id.to_owned(), - own_transaction_id: own_txn_id, - parent_key: None, - created_at, - }); - - // Save the new vector into db. - obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + self.with_transaction( + &[keys::DEPENDENT_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::DEPENDENT_SEND_QUEUE)?; + + // We store an encoded vector of the dependent requests. + // Reload the previous vector for this room, or create an empty one. + let prev = obj.get(&encoded_key).await?; + + let mut prev = prev.map_or_else( + || Ok(Vec::new()), + |val| self.deserialize_value::>(&val), + )?; + + // Push the new request. + prev.push(DependentQueuedRequest { + kind: content, + parent_transaction_id: parent_txn_id.to_owned(), + own_transaction_id: own_txn_id, + parent_key: None, + created_at, + }); + + // Save the new vector into db. + obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; - tx.commit().await?; + tx.commit().await?; - Ok(()) + Ok(()) + }, + ) + .await } async fn update_dependent_queued_request( @@ -1828,39 +1912,40 @@ impl_state_store!({ ) -> Result { let encoded_key = self.encode_key(keys::DEPENDENT_SEND_QUEUE, room_id); - let tx = self - .inner - .transaction(keys::DEPENDENT_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()?; - - let obj = tx.object_store(keys::DEPENDENT_SEND_QUEUE)?; - - // We store an encoded vector of the dependent requests. - // Reload the previous vector for this room, or create an empty one. - let prev = obj.get(&encoded_key).await?; - - let mut prev = prev.map_or_else( - || Ok(Vec::new()), - |val| self.deserialize_value::>(&val), - )?; - - // Modify the dependent request, if found. - let mut found = false; - for entry in prev.iter_mut() { - if entry.own_transaction_id == *own_transaction_id { - found = true; - entry.kind = new_content; - break; - } - } + self.with_transaction( + &[keys::DEPENDENT_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::DEPENDENT_SEND_QUEUE)?; + + // We store an encoded vector of the dependent requests. + // Reload the previous vector for this room, or create an empty one. + let prev = obj.get(&encoded_key).await?; + + let mut prev = prev.map_or_else( + || Ok(Vec::new()), + |val| self.deserialize_value::>(&val), + )?; + + // Modify the dependent request, if found. + let mut found = false; + for entry in prev.iter_mut() { + if entry.own_transaction_id == *own_transaction_id { + found = true; + entry.kind = new_content; + break; + } + } - if found { - obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; - tx.commit().await?; - } + if found { + obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + tx.commit().await?; + } - Ok(found) + Ok(found) + }, + ) + .await } async fn mark_dependent_queued_requests_as_ready( @@ -1871,36 +1956,39 @@ impl_state_store!({ ) -> Result { let encoded_key = self.encode_key(keys::DEPENDENT_SEND_QUEUE, room_id); - let tx = self - .inner - .transaction(keys::DEPENDENT_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()?; - - let obj = tx.object_store(keys::DEPENDENT_SEND_QUEUE)?; - - // We store an encoded vector of the dependent requests. - // Reload the previous vector for this room, or create an empty one. - let prev = obj.get(&encoded_key).await?; - - let mut prev = prev.map_or_else( - || Ok(Vec::new()), - |val| self.deserialize_value::>(&val), - )?; - - // Modify all requests that match. - let mut num_updated = 0; - for entry in prev.iter_mut().filter(|entry| entry.parent_transaction_id == parent_txn_id) { - entry.parent_key = Some(parent_key.clone()); - num_updated += 1; - } + self.with_transaction( + &[keys::DEPENDENT_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::DEPENDENT_SEND_QUEUE)?; + + // We store an encoded vector of the dependent requests. + // Reload the previous vector for this room, or create an empty one. + let prev = obj.get(&encoded_key).await?; + + let mut prev = prev.map_or_else( + || Ok(Vec::new()), + |val| self.deserialize_value::>(&val), + )?; + + // Modify all requests that match. + let mut num_updated = 0; + for entry in + prev.iter_mut().filter(|entry| entry.parent_transaction_id == parent_txn_id) + { + entry.parent_key = Some(parent_key.clone()); + num_updated += 1; + } - if num_updated > 0 { - obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; - tx.commit().await?; - } + if num_updated > 0 { + obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + tx.commit().await?; + } - Ok(num_updated) + Ok(num_updated) + }, + ) + .await } async fn remove_dependent_queued_request( @@ -1910,33 +1998,36 @@ impl_state_store!({ ) -> Result { let encoded_key = self.encode_key(keys::DEPENDENT_SEND_QUEUE, room_id); - let tx = self - .inner - .transaction(keys::DEPENDENT_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()?; + self.with_transaction( + &[keys::DEPENDENT_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::DEPENDENT_SEND_QUEUE)?; + + // We store an encoded vector of the dependent requests. + // Reload the previous vector for this room. + if let Some(val) = obj.get(&encoded_key).await? { + let mut prev = self.deserialize_value::>(&val)?; + if let Some(pos) = + prev.iter().position(|item| item.own_transaction_id == *txn_id) + { + prev.remove(pos); - let obj = tx.object_store(keys::DEPENDENT_SEND_QUEUE)?; + if prev.is_empty() { + obj.delete(&encoded_key).build()?; + } else { + obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + } - // We store an encoded vector of the dependent requests. - // Reload the previous vector for this room. - if let Some(val) = obj.get(&encoded_key).await? { - let mut prev = self.deserialize_value::>(&val)?; - if let Some(pos) = prev.iter().position(|item| item.own_transaction_id == *txn_id) { - prev.remove(pos); - - if prev.is_empty() { - obj.delete(&encoded_key).build()?; - } else { - obj.put(&self.serialize_value(&prev)?).with_key(encoded_key).build()?; + tx.commit().await?; + return Ok(true); + } } - tx.commit().await?; - return Ok(true); - } - } - - Ok(false) + Ok(false) + }, + ) + .await } async fn load_dependent_queued_requests( @@ -1947,12 +2038,16 @@ impl_state_store!({ // We store an encoded vector of the dependent requests. let prev = self - .inner - .transaction(keys::DEPENDENT_SEND_QUEUE) - .with_mode(TransactionMode::Readwrite) - .build()? - .object_store(keys::DEPENDENT_SEND_QUEUE)? - .get(&encoded_key) + .with_transaction( + &[keys::DEPENDENT_SEND_QUEUE], + TransactionMode::Readwrite, + async move |tx| { + tx.object_store(keys::DEPENDENT_SEND_QUEUE)? + .get(&encoded_key) + .await + .map_err(Into::into) + }, + ) .await?; prev.map_or_else( @@ -1965,41 +2060,44 @@ impl_state_store!({ &self, updates: Vec<(&RoomId, &EventId, StoredThreadSubscription)>, ) -> Result<()> { - let tx = self - .inner - .transaction(keys::THREAD_SUBSCRIPTIONS) - .with_mode(TransactionMode::Readwrite) - .build()?; - let obj = tx.object_store(keys::THREAD_SUBSCRIPTIONS)?; - - for (room_id, thread_id, subscription) in updates { - let encoded_key = self.encode_key(keys::THREAD_SUBSCRIPTIONS, (room_id, thread_id)); - let mut new = PersistedThreadSubscription::from(subscription); - - // See if there's a previous subscription. - if let Some(previous_value) = obj.get(&encoded_key).await? { - let previous: PersistedThreadSubscription = - self.deserialize_value(&previous_value)?; - - // If the previous status is the same as the new one, don't do anything. - if new == previous { - continue; - } - if !compare_thread_subscription_bump_stamps( - previous.bump_stamp, - &mut new.bump_stamp, - ) { - continue; - } - } + self.with_transaction( + &[keys::THREAD_SUBSCRIPTIONS], + TransactionMode::Readwrite, + async move |tx| { + let obj = tx.object_store(keys::THREAD_SUBSCRIPTIONS)?; + + for (room_id, thread_id, subscription) in updates { + let encoded_key = + self.encode_key(keys::THREAD_SUBSCRIPTIONS, (room_id, thread_id)); + let mut new = PersistedThreadSubscription::from(subscription); + + // See if there's a previous subscription. + if let Some(previous_value) = obj.get(&encoded_key).await? { + let previous: PersistedThreadSubscription = + self.deserialize_value(&previous_value)?; + + // If the previous status is the same as the new one, don't do anything. + if new == previous { + continue; + } + if !compare_thread_subscription_bump_stamps( + previous.bump_stamp, + &mut new.bump_stamp, + ) { + continue; + } + } - let serialized_value = self.serialize_value(&new); - obj.put(&serialized_value?).with_key(encoded_key).build()?; - } + let serialized_value = self.serialize_value(&new); + obj.put(&serialized_value?).with_key(encoded_key).build()?; + } - tx.commit().await?; + tx.commit().await?; - Ok(()) + Ok(()) + }, + ) + .await } async fn load_thread_subscription( @@ -2010,12 +2108,16 @@ impl_state_store!({ let encoded_key = self.encode_key(keys::THREAD_SUBSCRIPTIONS, (room, thread_id)); let js_value = self - .inner - .transaction(keys::THREAD_SUBSCRIPTIONS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::THREAD_SUBSCRIPTIONS)? - .get(&encoded_key) + .with_transaction( + &[keys::THREAD_SUBSCRIPTIONS], + TransactionMode::Readonly, + async move |tx| { + tx.object_store(keys::THREAD_SUBSCRIPTIONS)? + .get(&encoded_key) + .await + .map_err(Into::into) + }, + ) .await?; let Some(js_value) = js_value else { @@ -2040,15 +2142,17 @@ impl_state_store!({ async fn remove_thread_subscription(&self, room: &RoomId, thread_id: &EventId) -> Result<()> { let encoded_key = self.encode_key(keys::THREAD_SUBSCRIPTIONS, (room, thread_id)); - let transaction = self - .inner - .transaction(keys::THREAD_SUBSCRIPTIONS) - .with_mode(TransactionMode::Readwrite) - .build()?; - transaction.object_store(keys::THREAD_SUBSCRIPTIONS)?.delete(&encoded_key).await?; - transaction.commit().await?; + self.with_transaction( + &[keys::THREAD_SUBSCRIPTIONS], + TransactionMode::Readwrite, + async move |transaction| { + transaction.object_store(keys::THREAD_SUBSCRIPTIONS)?.delete(&encoded_key).await?; + transaction.commit().await?; - Ok(()) + Ok(()) + }, + ) + .await } #[allow(clippy::unused_async)] @@ -2066,9 +2170,18 @@ impl_state_store!({ Ok(()) } - #[allow(clippy::unused_async)] async fn reopen(&self) -> Result<()> { - Ok(()) + self.connection + .reopen(|name| async move { + upgrade_inner_db( + &name, + self.store_cipher.as_deref(), + MigrationConflictStrategy::Raise, + &self.meta, + ) + .await + }) + .await } }); From 5944a85c51e7601623ce73f739b4cfbc906d73c7 Mon Sep 17 00:00:00 2001 From: Daniel Salinas Date: Thu, 18 Jun 2026 19:52:55 +0000 Subject: [PATCH 3/5] fix(indexeddb): reopen crypto-store connection when the browser closes it Route every crypto-store transaction (account, identity, sessions, inbound/ outbound group sessions, devices, identities, gossip/secret requests, backup keys, withheld sessions, room settings, key bundles, custom values, lease locks) through `IndexeddbConnection::with_transaction`, and implement `reopen()`. The crypto store held a long-lived `inner: Database` handle with no-op close/reopen, so a browser-closed connection bricked E2EE key access until app restart. The handle is now an `IndexeddbConnection` that reopens (via `open_and_upgrade_db` with the retained serializer) and retries once on a DOM `InvalidStateError`. The Drop impl closes the current handle via the connection. With this, all four IndexedDB stores (event cache, media, state, crypto) share one reopen-and-retry mechanism and recover from a transient connection loss instead of bricking until restart. --- .../src/crypto_store/mod.rs | 1066 +++++++++-------- 1 file changed, 554 insertions(+), 512 deletions(-) diff --git a/crates/matrix-sdk-indexeddb/src/crypto_store/mod.rs b/crates/matrix-sdk-indexeddb/src/crypto_store/mod.rs index 7bef671d071..c9c75311556 100644 --- a/crates/matrix-sdk-indexeddb/src/crypto_store/mod.rs +++ b/crates/matrix-sdk-indexeddb/src/crypto_store/mod.rs @@ -62,6 +62,7 @@ use tracing::{debug, warn}; use wasm_bindgen::JsValue; use crate::{ + connection::IndexeddbConnection, crypto_store::migrations::open_and_upgrade_db, error::GenericError, serializer::{MaybeEncrypted, SafeEncodeSerializer, SafeEncodeSerializerError}, @@ -136,8 +137,9 @@ mod keys { /// [IndexedDB]: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API pub struct IndexeddbCryptoStore { static_account: RwLock>, - name: String, - pub(crate) inner: Database, + // A reopenable handle to the IndexedDB database. See [`IndexeddbConnection`] + // for why the handle must be reopenable rather than a plain `Database`. + pub(crate) connection: IndexeddbConnection, serializer: SafeEncodeSerializer, save_changes_lock: Arc>, @@ -146,7 +148,34 @@ pub struct IndexeddbCryptoStore { #[cfg(not(tarpaulin_include))] impl std::fmt::Debug for IndexeddbCryptoStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("IndexeddbCryptoStore").field("name", &self.name).finish() + f.debug_struct("IndexeddbCryptoStore").field("name", &self.connection.name()).finish() + } +} + +impl IndexeddbCryptoStore { + /// Run `f` against a fresh transaction over `stores`, reopening the + /// connection and retrying once if the browser has closed it. + /// + /// Without this, a transient connection loss would permanently brick the + /// crypto store — key reads/writes would keep failing until the app is + /// restarted. + async fn with_transaction( + &self, + stores: &[&str], + mode: TransactionMode, + f: F, + ) -> Result + where + F: AsyncFnOnce(Transaction<'_>) -> Result, + { + self.connection + .with_transaction( + stores, + mode, + |name| async move { open_and_upgrade_db(&name, &self.serializer).await }, + f, + ) + .await } } @@ -317,7 +346,7 @@ impl PendingIndexeddbChanges { /// Returns the list of stores that have pending operations. /// Should be used as the list of store names when starting the indexeddb /// transaction (`transaction_on_multi_with_mode`). - fn touched_stores(&self) -> Vec<&str> { + fn touched_stores(&self) -> Vec<&'static str> { self.store_to_key_values .iter() .filter_map( @@ -373,8 +402,7 @@ impl IndexeddbCryptoStore { let db = open_and_upgrade_db(&name, &serializer).await?; Ok(Self { - name, - inner: db, + connection: IndexeddbConnection::new(name, db), serializer, static_account: RwLock::new(None), save_changes_lock: Default::default(), @@ -843,25 +871,26 @@ impl_crypto_store! { return Ok(()); } - let tx = self.inner.transaction(stores).with_mode(TransactionMode::Readwrite).build()?; - - let account_pickle = if let Some(account) = changes.account { - *self.static_account.write().unwrap() = Some(account.static_data().clone()); - Some(account.pickle()) - } else { - None - }; + self.with_transaction(&stores, TransactionMode::Readwrite, async move |tx| { + let account_pickle = if let Some(account) = changes.account { + *self.static_account.write().unwrap() = Some(account.static_data().clone()); + Some(account.pickle()) + } else { + None + }; - if let Some(a) = &account_pickle { - tx.object_store(keys::CORE)? - .put(&self.serializer.serialize_value(&a)?) - .with_key(JsValue::from_str(keys::ACCOUNT)) - .build()?; - } + if let Some(a) = &account_pickle { + tx.object_store(keys::CORE)? + .put(&self.serializer.serialize_value(&a)?) + .with_key(JsValue::from_str(keys::ACCOUNT)) + .build()?; + } - tx.commit().await?; + tx.commit().await?; - Ok(()) + Ok(()) + }) + .await } async fn save_changes(&self, changes: Changes) -> Result<()> { @@ -880,13 +909,14 @@ impl_crypto_store! { return Ok(()); } - let tx = self.inner.transaction(stores).with_mode(TransactionMode::Readwrite).build()?; - - indexeddb_changes.apply(&tx).await?; + self.with_transaction(&stores, TransactionMode::Readwrite, async move |tx| { + indexeddb_changes.apply(&tx).await?; - tx.commit().await?; + tx.commit().await?; - Ok(()) + Ok(()) + }) + .await } async fn save_inbound_group_sessions( @@ -912,28 +942,28 @@ impl_crypto_store! { } async fn load_tracked_users(&self) -> Result> { - let tx = self - .inner - .transaction(keys::TRACKED_USERS) - .with_mode(TransactionMode::Readonly) - .build()?; - let os = tx.object_store(keys::TRACKED_USERS)?; - let user_ids = os.get_all_keys::().await?; - - let mut users = Vec::new(); - - for result in user_ids { - let user_id = result?; - let dirty: bool = !matches!( - os.get(&user_id).await?.map(|v: JsValue| v.into_serde()), - Some(Ok(false)) - ); - let Some(Ok(user_id)) = user_id.as_string().map(UserId::parse) else { continue }; + self.with_transaction(&[keys::TRACKED_USERS], TransactionMode::Readonly, async move |tx| { + let os = tx.object_store(keys::TRACKED_USERS)?; + let user_ids = os.get_all_keys::().await?; - users.push(TrackedUser { user_id, dirty }); - } + let mut users = Vec::new(); + + for result in user_ids { + let user_id = result?; + let dirty: bool = !matches!( + os.get(&user_id).await?.map(|v: JsValue| v.into_serde()), + Some(Ok(false)) + ); + let Some(Ok(user_id)) = user_id.as_string().map(UserId::parse) else { + continue; + }; - Ok(users) + users.push(TrackedUser { user_id, dirty }); + } + + Ok(users) + }) + .await } async fn get_outbound_group_session( @@ -941,15 +971,19 @@ impl_crypto_store! { room_id: &RoomId, ) -> Result> { let account_info = self.get_static_account().ok_or(CryptoStoreError::AccountUnset)?; - if let Some(value) = self - .inner - .transaction(keys::OUTBOUND_GROUP_SESSIONS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::OUTBOUND_GROUP_SESSIONS)? - .get(&self.serializer.encode_key(keys::OUTBOUND_GROUP_SESSIONS, room_id)) - .await? - { + let value = self + .with_transaction( + &[keys::OUTBOUND_GROUP_SESSIONS], + TransactionMode::Readonly, + async move |tx| { + tx.object_store(keys::OUTBOUND_GROUP_SESSIONS)? + .get(&self.serializer.encode_key(keys::OUTBOUND_GROUP_SESSIONS, room_id)) + .await + .map_err(Into::into) + }, + ) + .await?; + if let Some(value) = value { Ok(Some( OutboundGroupSession::from_pickle( account_info.device_id, @@ -968,27 +1002,26 @@ impl_crypto_store! { request_id: &TransactionId, ) -> Result> { let jskey = self.serializer.encode_key(keys::GOSSIP_REQUESTS, request_id.as_str()); - self.inner - .transaction(keys::GOSSIP_REQUESTS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::GOSSIP_REQUESTS)? - .get(jskey) - .await? - .map(|val| self.deserialize_gossip_request(val)) - .transpose() + self.with_transaction(&[keys::GOSSIP_REQUESTS], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::GOSSIP_REQUESTS)? + .get(jskey) + .await? + .map(|val| self.deserialize_gossip_request(val)) + .transpose() + }) + .await } async fn load_account(&self) -> Result> { - if let Some(pickle) = self - .inner - .transaction(keys::CORE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::CORE)? - .get(&JsValue::from_str(keys::ACCOUNT)) - .await? - { + let pickle = self + .with_transaction(&[keys::CORE], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::CORE)? + .get(&JsValue::from_str(keys::ACCOUNT)) + .await + .map_err(Into::into) + }) + .await?; + if let Some(pickle) = pickle { let pickle = self.serializer.deserialize_value(pickle)?; let account = Account::from_pickle(pickle).map_err(CryptoStoreError::from)?; @@ -1002,15 +1035,15 @@ impl_crypto_store! { } async fn next_batch_token(&self) -> Result> { - if let Some(serialized) = self - .inner - .transaction(keys::CORE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::CORE)? - .get(&JsValue::from_str(keys::NEXT_BATCH_TOKEN)) - .await? - { + let serialized = self + .with_transaction(&[keys::CORE], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::CORE)? + .get(&JsValue::from_str(keys::NEXT_BATCH_TOKEN)) + .await + .map_err(Into::into) + }) + .await?; + if let Some(serialized) = serialized { let token = self.serializer.deserialize_value(serialized)?; Ok(Some(token)) } else { @@ -1019,15 +1052,15 @@ impl_crypto_store! { } async fn load_identity(&self) -> Result> { - if let Some(pickle) = self - .inner - .transaction(keys::CORE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::CORE)? - .get(&JsValue::from_str(keys::PRIVATE_IDENTITY)) - .await? - { + let pickle = self + .with_transaction(&[keys::CORE], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::CORE)? + .get(&JsValue::from_str(keys::PRIVATE_IDENTITY)) + .await + .map_err(Into::into) + }) + .await?; + if let Some(pickle) = pickle { let pickle = self.serializer.deserialize_value(pickle)?; Ok(Some( @@ -1044,23 +1077,24 @@ impl_crypto_store! { let range = self.serializer.encode_to_range(keys::SESSION, sender_key); let sessions: Vec = self - .inner - .transaction(keys::SESSION) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::SESSION)? - .get_all() - .with_query(&range) - .await? - .filter_map(Result::ok) - .filter_map(|f| { - self.serializer.deserialize_value(f).ok().map(|p| { - Session::from_pickle(device_keys.clone(), p).map_err(|_| { - IndexeddbCryptoStoreError::CryptoStoreError(CryptoStoreError::AccountUnset) + .with_transaction(&[keys::SESSION], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::SESSION)? + .get_all() + .with_query(&range) + .await? + .filter_map(Result::ok) + .filter_map(|f| { + self.serializer.deserialize_value(f).ok().map(|p| { + Session::from_pickle(device_keys.clone(), p).map_err(|_| { + IndexeddbCryptoStoreError::CryptoStoreError( + CryptoStoreError::AccountUnset, + ) + }) + }) }) - }) + .collect::>>() }) - .collect::>>()?; + .await?; if sessions.is_empty() { Ok(None) @@ -1076,15 +1110,16 @@ impl_crypto_store! { ) -> Result> { let key = self.serializer.encode_key(keys::INBOUND_GROUP_SESSIONS_V3, (room_id, session_id)); - if let Some(value) = self - .inner - .transaction(keys::INBOUND_GROUP_SESSIONS_V3) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::INBOUND_GROUP_SESSIONS_V3)? - .get(&key) - .await? - { + let value = self + .with_transaction( + &[keys::INBOUND_GROUP_SESSIONS_V3], + TransactionMode::Readonly, + async move |tx| { + tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?.get(&key).await.map_err(Into::into) + }, + ) + .await?; + if let Some(value) = value { Ok(Some(self.deserialize_inbound_group_session(value)?)) } else { Ok(None) @@ -1094,18 +1129,20 @@ impl_crypto_store! { async fn get_inbound_group_sessions(&self) -> Result> { const INBOUND_GROUP_SESSIONS_BATCH_SIZE: usize = 1000; - let transaction = self - .inner - .transaction(keys::INBOUND_GROUP_SESSIONS_V3) - .with_mode(TransactionMode::Readonly) - .build()?; - - let object_store = transaction.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; - - fetch_from_object_store_batched( - object_store, - |value| self.deserialize_inbound_group_session(value), - INBOUND_GROUP_SESSIONS_BATCH_SIZE, + self.with_transaction( + &[keys::INBOUND_GROUP_SESSIONS_V3], + TransactionMode::Readonly, + async move |transaction| { + let object_store = + transaction.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; + + fetch_from_object_store_batched( + object_store, + |value| self.deserialize_inbound_group_session(value), + INBOUND_GROUP_SESSIONS_BATCH_SIZE, + ) + .await + }, ) .await } @@ -1115,24 +1152,27 @@ impl_crypto_store! { room_id: &RoomId, ) -> Result> { let range = self.serializer.encode_to_range(keys::INBOUND_GROUP_SESSIONS_V3, room_id); - Ok(self - .inner - .transaction(keys::INBOUND_GROUP_SESSIONS_V3) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::INBOUND_GROUP_SESSIONS_V3)? - .get_all() - .with_query(&range) - .await? - .filter_map(Result::ok) - .filter_map(|v| match self.deserialize_inbound_group_session(v) { - Ok(session) => Some(session), - Err(e) => { - warn!("Failed to deserialize inbound group session: {e}"); - None - } - }) - .collect::>()) + self.with_transaction( + &[keys::INBOUND_GROUP_SESSIONS_V3], + TransactionMode::Readonly, + async move |tx| { + Ok(tx + .object_store(keys::INBOUND_GROUP_SESSIONS_V3)? + .get_all() + .with_query(&range) + .await? + .filter_map(Result::ok) + .filter_map(|v| match self.deserialize_inbound_group_session(v) { + Ok(session) => Some(session), + Err(e) => { + warn!("Failed to deserialize inbound group session: {e}"); + None + } + }) + .collect::>()) + }, + ) + .await } async fn get_inbound_group_sessions_for_device_batch( @@ -1158,16 +1198,21 @@ impl_crypto_store! { [sender_key, ((sender_data_type as u8) + 1).into()].iter().collect(); let key = KeyRange::Bound(lower_bound, true, upper_bound, true); - let tx = self - .inner - .transaction(keys::INBOUND_GROUP_SESSIONS_V3) - .with_mode(TransactionMode::Readonly) - .build()?; - - let store = tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; - let idx = store.index(keys::INBOUND_GROUP_SESSIONS_SENDER_KEY_INDEX)?; - let serialized_sessions = - idx.get_all().with_query::(key).with_limit(limit as u32).await?; + let serialized_sessions = self + .with_transaction( + &[keys::INBOUND_GROUP_SESSIONS_V3], + TransactionMode::Readonly, + async move |tx| { + let store = tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; + let idx = store.index(keys::INBOUND_GROUP_SESSIONS_SENDER_KEY_INDEX)?; + idx.get_all() + .with_query::(key) + .with_limit(limit as u32) + .await + .map_err(Into::into) + }, + ) + .await?; // Deserialize and decrypt after the transaction is complete. let result = serialized_sessions @@ -1188,17 +1233,21 @@ impl_crypto_store! { &self, _backup_version: Option<&str>, ) -> Result { - let tx = self - .inner - .transaction(keys::INBOUND_GROUP_SESSIONS_V3) - .with_mode(TransactionMode::Readonly) - .build()?; - let store = tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; - let all = store.count().await? as usize; - let not_backed_up = - store.index(keys::INBOUND_GROUP_SESSIONS_BACKUP_INDEX)?.count().await? as usize; - tx.commit().await?; - Ok(RoomKeyCounts { total: all, backed_up: all - not_backed_up }) + self.with_transaction( + &[keys::INBOUND_GROUP_SESSIONS_V3], + TransactionMode::Readonly, + async move |tx| { + let store = tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; + let all = store.count().await? as usize; + let not_backed_up = store + .index(keys::INBOUND_GROUP_SESSIONS_BACKUP_INDEX)? + .count() + .await? as usize; + tx.commit().await?; + Ok(RoomKeyCounts { total: all, backed_up: all - not_backed_up }) + }, + ) + .await } async fn inbound_group_sessions_for_backup( @@ -1206,31 +1255,34 @@ impl_crypto_store! { _backup_version: &str, limit: usize, ) -> Result> { - let tx = self - .inner - .transaction(keys::INBOUND_GROUP_SESSIONS_V3) - .with_mode(TransactionMode::Readonly) - .build()?; - - let store = tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; - let idx = store.index(keys::INBOUND_GROUP_SESSIONS_BACKUP_INDEX)?; - - // XXX ideally we would use `get_all_with_key_and_limit`, but that doesn't - // appear to be exposed (https://github.com/Alorel/rust-indexed-db/issues/31). Instead we replicate - // the behaviour with a cursor. - let Some(mut cursor) = idx.open_cursor().await? else { - return Ok(vec![]); - }; - - let mut serialized_sessions = Vec::with_capacity(limit); - for _ in 0..limit { - let Some(value) = cursor.next_record().await? else { - break; - }; - serialized_sessions.push(value) - } + let serialized_sessions = self + .with_transaction( + &[keys::INBOUND_GROUP_SESSIONS_V3], + TransactionMode::Readonly, + async move |tx| { + let store = tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; + let idx = store.index(keys::INBOUND_GROUP_SESSIONS_BACKUP_INDEX)?; + + // XXX ideally we would use `get_all_with_key_and_limit`, but that doesn't + // appear to be exposed (https://github.com/Alorel/rust-indexed-db/issues/31). Instead we replicate + // the behaviour with a cursor. + let Some(mut cursor) = idx.open_cursor().await? else { + return Ok(Vec::new()); + }; + + let mut serialized_sessions = Vec::with_capacity(limit); + for _ in 0..limit { + let Some(value) = cursor.next_record().await? else { + break; + }; + serialized_sessions.push(value) + } - tx.commit().await?; + tx.commit().await?; + Ok(serialized_sessions) + }, + ) + .await?; // Deserialize and decrypt after the transaction is complete. let result = serialized_sessions @@ -1252,74 +1304,80 @@ impl_crypto_store! { _backup_version: &str, room_and_session_ids: &[(&RoomId, &str)], ) -> Result<()> { - let tx = self - .inner - .transaction(keys::INBOUND_GROUP_SESSIONS_V3) - .with_mode(TransactionMode::Readwrite) - .build()?; - - let object_store = tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; - - for (room_id, session_id) in room_and_session_ids { - let key = - self.serializer.encode_key(keys::INBOUND_GROUP_SESSIONS_V3, (room_id, session_id)); - if let Some(idb_object_js) = object_store.get(&key).await? { - let mut idb_object: InboundGroupSessionIndexedDbObject = - serde_wasm_bindgen::from_value(idb_object_js)?; - idb_object.needs_backup = false; - object_store - .put(&serde_wasm_bindgen::to_value(&idb_object)?) - .with_key(key) - .build()?; - } else { - warn!(?key, "Could not find inbound group session to mark it as backed up."); - } - } + self.with_transaction( + &[keys::INBOUND_GROUP_SESSIONS_V3], + TransactionMode::Readwrite, + async move |tx| { + let object_store = tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?; + + for (room_id, session_id) in room_and_session_ids { + let key = self + .serializer + .encode_key(keys::INBOUND_GROUP_SESSIONS_V3, (room_id, session_id)); + if let Some(idb_object_js) = object_store.get(&key).await? { + let mut idb_object: InboundGroupSessionIndexedDbObject = + serde_wasm_bindgen::from_value(idb_object_js)?; + idb_object.needs_backup = false; + object_store + .put(&serde_wasm_bindgen::to_value(&idb_object)?) + .with_key(key) + .build()?; + } else { + warn!( + ?key, + "Could not find inbound group session to mark it as backed up." + ); + } + } - Ok(tx.commit().await?) + Ok(tx.commit().await?) + }, + ) + .await } async fn reset_backup_state(&self) -> Result<()> { - let tx = self - .inner - .transaction(keys::INBOUND_GROUP_SESSIONS_V3) - .with_mode(TransactionMode::Readwrite) - .build()?; - - if let Some(mut cursor) = - tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?.open_cursor().await? - { - while let Some(value) = cursor.next_record().await? { - let mut idb_object: InboundGroupSessionIndexedDbObject = - serde_wasm_bindgen::from_value(value)?; - if !idb_object.needs_backup { - idb_object.needs_backup = true; - // We don't bother to update the encrypted `InboundGroupSession` object stored - // inside `idb_object.data`, since that would require decryption and encryption. - // Instead, it will be patched up by `deserialize_inbound_group_session`. - let idb_object = serde_wasm_bindgen::to_value(&idb_object)?; - cursor.update(&idb_object).await?; + self.with_transaction( + &[keys::INBOUND_GROUP_SESSIONS_V3], + TransactionMode::Readwrite, + async move |tx| { + if let Some(mut cursor) = + tx.object_store(keys::INBOUND_GROUP_SESSIONS_V3)?.open_cursor().await? + { + while let Some(value) = cursor.next_record().await? { + let mut idb_object: InboundGroupSessionIndexedDbObject = + serde_wasm_bindgen::from_value(value)?; + if !idb_object.needs_backup { + idb_object.needs_backup = true; + // We don't bother to update the encrypted `InboundGroupSession` object stored + // inside `idb_object.data`, since that would require decryption and encryption. + // Instead, it will be patched up by `deserialize_inbound_group_session`. + let idb_object = serde_wasm_bindgen::to_value(&idb_object)?; + cursor.update(&idb_object).await?; + } + } } - } - } - Ok(tx.commit().await?) + Ok(tx.commit().await?) + }, + ) + .await } async fn save_tracked_users(&self, users: &[(&UserId, bool)]) -> Result<()> { - let tx = self - .inner - .transaction(keys::TRACKED_USERS) - .with_mode(TransactionMode::Readwrite) - .build()?; - let os = tx.object_store(keys::TRACKED_USERS)?; - - for (user, dirty) in users { - os.put(&JsValue::from(*dirty)).with_key(JsValue::from_str(user.as_str())).build()?; - } + self.with_transaction(&[keys::TRACKED_USERS], TransactionMode::Readwrite, async move |tx| { + let os = tx.object_store(keys::TRACKED_USERS)?; - tx.commit().await?; - Ok(()) + for (user, dirty) in users { + os.put(&JsValue::from(*dirty)) + .with_key(JsValue::from_str(user.as_str())) + .build()?; + } + + tx.commit().await?; + Ok(()) + }) + .await } async fn get_device( @@ -1328,15 +1386,14 @@ impl_crypto_store! { device_id: &DeviceId, ) -> Result> { let key = self.serializer.encode_key(keys::DEVICES, (user_id, device_id)); - self.inner - .transaction(keys::DEVICES) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::DEVICES)? - .get(&key) - .await? - .map(|i| self.serializer.deserialize_value(i).map_err(Into::into)) - .transpose() + self.with_transaction(&[keys::DEVICES], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::DEVICES)? + .get(&key) + .await? + .map(|i| self.serializer.deserialize_value(i).map_err(Into::into)) + .transpose() + }) + .await } async fn get_user_devices( @@ -1344,21 +1401,20 @@ impl_crypto_store! { user_id: &UserId, ) -> Result> { let range = self.serializer.encode_to_range(keys::DEVICES, user_id); - Ok(self - .inner - .transaction(keys::DEVICES) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::DEVICES)? - .get_all() - .with_query(&range) - .await? - .filter_map(Result::ok) - .filter_map(|d| { - let d: DeviceData = self.serializer.deserialize_value(d).ok()?; - Some((d.device_id().to_owned(), d)) - }) - .collect::>()) + self.with_transaction(&[keys::DEVICES], TransactionMode::Readonly, async move |tx| { + Ok(tx + .object_store(keys::DEVICES)? + .get_all() + .with_query(&range) + .await? + .filter_map(Result::ok) + .filter_map(|d| { + let d: DeviceData = self.serializer.deserialize_value(d).ok()?; + Some((d.device_id().to_owned(), d)) + }) + .collect::>()) + }) + .await } async fn get_own_device(&self) -> Result { @@ -1367,29 +1423,27 @@ impl_crypto_store! { } async fn get_user_identity(&self, user_id: &UserId) -> Result> { - self.inner - .transaction(keys::IDENTITIES) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::IDENTITIES)? - .get(&self.serializer.encode_key(keys::IDENTITIES, user_id)) - .await? - .map(|i| self.serializer.deserialize_value(i).map_err(Into::into)) - .transpose() + self.with_transaction(&[keys::IDENTITIES], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::IDENTITIES)? + .get(&self.serializer.encode_key(keys::IDENTITIES, user_id)) + .await? + .map(|i| self.serializer.deserialize_value(i).map_err(Into::into)) + .transpose() + }) + .await } async fn is_message_known(&self, hash: &OlmMessageHash) -> Result { - Ok(self - .inner - .transaction(keys::OLM_HASHES) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::OLM_HASHES)? - .get::( - &self.serializer.encode_key(keys::OLM_HASHES, (&hash.sender_key, &hash.hash)), - ) - .await? - .is_some()) + self.with_transaction(&[keys::OLM_HASHES], TransactionMode::Readonly, async move |tx| { + Ok(tx + .object_store(keys::OLM_HASHES)? + .get::( + &self.serializer.encode_key(keys::OLM_HASHES, (&hash.sender_key, &hash.hash)), + ) + .await? + .is_some()) + }) + .await } async fn get_secrets_from_inbox( @@ -1398,35 +1452,32 @@ impl_crypto_store! { ) -> Result>> { let range = self.serializer.encode_to_range(keys::SECRETS_INBOX_V2, secret_name.as_str()); - self.inner - .transaction(keys::SECRETS_INBOX_V2) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::SECRETS_INBOX_V2)? - .get_all() - .with_query(&range) - .await? - .map(|result| { - let d = result?; - let secret: String = self.serializer.deserialize_value(d)?; - Ok(secret.into()) - }) - .collect() + self.with_transaction(&[keys::SECRETS_INBOX_V2], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::SECRETS_INBOX_V2)? + .get_all() + .with_query(&range) + .await? + .map(|result| { + let d = result?; + let secret: String = self.serializer.deserialize_value(d)?; + Ok(secret.into()) + }) + .collect() + }) + .await } #[allow(clippy::unused_async)] // Mandated by trait on wasm. async fn delete_secrets_from_inbox(&self, secret_name: &SecretName) -> Result<()> { let range = self.serializer.encode_to_range(keys::SECRETS_INBOX_V2, secret_name.as_str()); - let transaction = self - .inner - .transaction(keys::SECRETS_INBOX_V2) - .with_mode(TransactionMode::Readwrite) - .build()?; - transaction.object_store(keys::SECRETS_INBOX_V2)?.delete(&range).build()?; - transaction.commit().await?; + self.with_transaction(&[keys::SECRETS_INBOX_V2], TransactionMode::Readwrite, async move |transaction| { + transaction.object_store(keys::SECRETS_INBOX_V2)?.delete(&range).build()?; + transaction.commit().await?; - Ok(()) + Ok(()) + }) + .await } async fn get_secret_request_by_info( @@ -1436,13 +1487,13 @@ impl_crypto_store! { let key = self.serializer.encode_key(keys::GOSSIP_REQUESTS, key_info.as_key()); let val = self - .inner - .transaction(keys::GOSSIP_REQUESTS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::GOSSIP_REQUESTS)? - .index(keys::GOSSIP_REQUESTS_BY_INFO_INDEX)? - .get(key) + .with_transaction(&[keys::GOSSIP_REQUESTS], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::GOSSIP_REQUESTS)? + .index(keys::GOSSIP_REQUESTS_BY_INFO_INDEX)? + .get(key) + .await + .map_err(Into::into) + }) .await?; if let Some(val) = val { @@ -1454,40 +1505,30 @@ impl_crypto_store! { } async fn get_unsent_secret_requests(&self) -> Result> { - let results = self - .inner - .transaction(keys::GOSSIP_REQUESTS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::GOSSIP_REQUESTS)? - .index(keys::GOSSIP_REQUESTS_UNSENT_INDEX)? - .get_all() - .await? - .filter_map(Result::ok) - .filter_map(|val| self.deserialize_gossip_request(val).ok()) - .collect(); - - Ok(results) + self.with_transaction(&[keys::GOSSIP_REQUESTS], TransactionMode::Readonly, async move |tx| { + Ok(tx + .object_store(keys::GOSSIP_REQUESTS)? + .index(keys::GOSSIP_REQUESTS_UNSENT_INDEX)? + .get_all() + .await? + .filter_map(Result::ok) + .filter_map(|val| self.deserialize_gossip_request(val).ok()) + .collect()) + }) + .await } async fn delete_outgoing_secret_requests(&self, request_id: &TransactionId) -> Result<()> { let jskey = self.serializer.encode_key(keys::GOSSIP_REQUESTS, request_id); - let tx = self - .inner - .transaction(keys::GOSSIP_REQUESTS) - .with_mode(TransactionMode::Readwrite) - .build()?; - tx.object_store(keys::GOSSIP_REQUESTS)?.delete(jskey).build()?; - tx.commit().await.map_err(|e| e.into()) + self.with_transaction(&[keys::GOSSIP_REQUESTS], TransactionMode::Readwrite, async move |tx| { + tx.object_store(keys::GOSSIP_REQUESTS)?.delete(jskey).build()?; + tx.commit().await.map_err(|e| e.into()) + }) + .await } async fn load_backup_keys(&self) -> Result { - let key = { - let tx = self - .inner - .transaction(keys::BACKUP_KEYS) - .with_mode(TransactionMode::Readonly) - .build()?; + self.with_transaction(&[keys::BACKUP_KEYS], TransactionMode::Readonly, async move |tx| { let store = tx.object_store(keys::BACKUP_KEYS)?; let backup_version = store @@ -1502,22 +1543,21 @@ impl_crypto_store! { .map(|i| self.serializer.deserialize_value(i)) .transpose()?; - BackupKeys { backup_version, decryption_key } - }; - - Ok(key) + Ok(BackupKeys { backup_version, decryption_key }) + }) + .await } async fn load_dehydrated_device_pickle_key(&self) -> Result> { - if let Some(pickle) = self - .inner - .transaction(keys::CORE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::CORE)? - .get(&JsValue::from_str(keys::DEHYDRATION_PICKLE_KEY)) - .await? - { + let pickle = self + .with_transaction(&[keys::CORE], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::CORE)? + .get(&JsValue::from_str(keys::DEHYDRATION_PICKLE_KEY)) + .await + .map_err(Into::into) + }) + .await?; + if let Some(pickle) = pickle { let pickle: DehydratedDeviceKey = self.serializer.deserialize_value(pickle)?; Ok(Some(pickle)) @@ -1537,15 +1577,12 @@ impl_crypto_store! { session_id: &str, ) -> Result> { let key = self.serializer.encode_key(keys::WITHHELD_SESSIONS, (room_id, session_id)); - if let Some(pickle) = self - .inner - .transaction(keys::WITHHELD_SESSIONS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::WITHHELD_SESSIONS)? - .get(&key) - .await? - { + let pickle = self + .with_transaction(&[keys::WITHHELD_SESSIONS], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::WITHHELD_SESSIONS)?.get(&key).await.map_err(Into::into) + }) + .await?; + if let Some(pickle) = pickle { let info = self.serializer.deserialize_value(pickle)?; Ok(Some(info)) } else { @@ -1559,30 +1596,27 @@ impl_crypto_store! { ) -> Result> { let range = self.serializer.encode_to_range(keys::WITHHELD_SESSIONS, room_id); - self - .inner - .transaction(keys::WITHHELD_SESSIONS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::WITHHELD_SESSIONS)? - .get_all() - .with_query(&range) - .await? - .map(|val| self.serializer.deserialize_value(val?).map_err(Into::into)) - .collect() + self.with_transaction(&[keys::WITHHELD_SESSIONS], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::WITHHELD_SESSIONS)? + .get_all() + .with_query(&range) + .await? + .map(|val| self.serializer.deserialize_value(val?).map_err(Into::into)) + .collect() + }) + .await } async fn get_room_settings(&self, room_id: &RoomId) -> Result> { let key = self.serializer.encode_key(keys::ROOM_SETTINGS, room_id); - self.inner - .transaction(keys::ROOM_SETTINGS) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::ROOM_SETTINGS)? - .get(&key) - .await? - .map(|v| self.serializer.deserialize_value(v).map_err(Into::into)) - .transpose() + self.with_transaction(&[keys::ROOM_SETTINGS], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::ROOM_SETTINGS)? + .get(&key) + .await? + .map(|v| self.serializer.deserialize_value(v).map_err(Into::into)) + .transpose() + }) + .await } async fn get_received_room_key_bundle_data( @@ -1591,100 +1625,106 @@ impl_crypto_store! { user_id: &UserId, ) -> Result> { let key = self.serializer.encode_key(keys::RECEIVED_ROOM_KEY_BUNDLES, (room_id, user_id)); - let result = self - .inner - .transaction(keys::RECEIVED_ROOM_KEY_BUNDLES) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::RECEIVED_ROOM_KEY_BUNDLES)? - .get(&key) - .await? - .map(|v| self.serializer.deserialize_value(v)) - .transpose()?; - - Ok(result) + self.with_transaction( + &[keys::RECEIVED_ROOM_KEY_BUNDLES], + TransactionMode::Readonly, + async move |tx| { + tx.object_store(keys::RECEIVED_ROOM_KEY_BUNDLES)? + .get(&key) + .await? + .map(|v| self.serializer.deserialize_value(v)) + .transpose() + .map_err(Into::into) + }, + ) + .await } async fn has_downloaded_all_room_keys(&self, room_id: &RoomId) -> Result { let key = self.serializer.encode_key(keys::ROOM_KEY_BACKUPS_FULLY_DOWNLOADED, room_id); - let result = self - .inner - .transaction(keys::ROOM_KEY_BACKUPS_FULLY_DOWNLOADED) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::ROOM_KEY_BACKUPS_FULLY_DOWNLOADED)? - .get::(&key) - .await? - .is_some(); - - Ok(result) + self.with_transaction( + &[keys::ROOM_KEY_BACKUPS_FULLY_DOWNLOADED], + TransactionMode::Readonly, + async move |tx| { + Ok(tx + .object_store(keys::ROOM_KEY_BACKUPS_FULLY_DOWNLOADED)? + .get::(&key) + .await? + .is_some()) + }, + ) + .await } async fn get_pending_key_bundle_details_for_room(&self, room_id: &RoomId) -> Result> { let key = self.serializer.encode_key(keys::ROOMS_PENDING_KEY_BUNDLE, room_id); - let result = self - .inner - .transaction(keys::ROOMS_PENDING_KEY_BUNDLE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::ROOMS_PENDING_KEY_BUNDLE)? - .get(&key) - .await? - .map(|v| self.serializer.deserialize_value(v)) - .transpose()?; - Ok(result) + self.with_transaction( + &[keys::ROOMS_PENDING_KEY_BUNDLE], + TransactionMode::Readonly, + async move |tx| { + tx.object_store(keys::ROOMS_PENDING_KEY_BUNDLE)? + .get(&key) + .await? + .map(|v| self.serializer.deserialize_value(v)) + .transpose() + .map_err(Into::into) + }, + ) + .await } async fn get_all_rooms_pending_key_bundles(&self) -> Result> { - let result = self - .inner - .transaction(keys::ROOMS_PENDING_KEY_BUNDLE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::ROOMS_PENDING_KEY_BUNDLE)? - .get_all() - .await? - .map(|result| { - result - .map_err(Into::into) - .and_then(|v| self.serializer.deserialize_value(v).map_err(Into::into)) - }) - .collect::>>()?; - Ok(result) + self.with_transaction( + &[keys::ROOMS_PENDING_KEY_BUNDLE], + TransactionMode::Readonly, + async move |tx| { + tx.object_store(keys::ROOMS_PENDING_KEY_BUNDLE)? + .get_all() + .await? + .map(|result| { + result + .map_err(Into::into) + .and_then(|v| self.serializer.deserialize_value(v).map_err(Into::into)) + }) + .collect::>>() + }, + ) + .await } async fn get_custom_value(&self, key: &str) -> Result>> { - self.inner - .transaction(keys::CORE) - .with_mode(TransactionMode::Readonly) - .build()? - .object_store(keys::CORE)? - .get(&JsValue::from_str(key)) - .await? - .map(|v| self.serializer.deserialize_value(v).map_err(Into::into)) - .transpose() + self.with_transaction(&[keys::CORE], TransactionMode::Readonly, async move |tx| { + tx.object_store(keys::CORE)? + .get(&JsValue::from_str(key)) + .await? + .map(|v| self.serializer.deserialize_value(v).map_err(Into::into)) + .transpose() + }) + .await } #[allow(clippy::unused_async)] // Mandated by trait on wasm. async fn set_custom_value(&self, key: &str, value: Vec) -> Result<()> { - let transaction = - self.inner.transaction(keys::CORE).with_mode(TransactionMode::Readwrite).build()?; - transaction - .object_store(keys::CORE)? - .put(&self.serializer.serialize_value(&value)?) - .with_key(JsValue::from_str(key)) - .build()?; - transaction.commit().await?; - Ok(()) + self.with_transaction(&[keys::CORE], TransactionMode::Readwrite, async move |transaction| { + transaction + .object_store(keys::CORE)? + .put(&self.serializer.serialize_value(&value)?) + .with_key(JsValue::from_str(key)) + .build()?; + transaction.commit().await?; + Ok(()) + }) + .await } #[allow(clippy::unused_async)] // Mandated by trait on wasm. async fn remove_custom_value(&self, key: &str) -> Result<()> { - let transaction = - self.inner.transaction(keys::CORE).with_mode(TransactionMode::Readwrite).build()?; - transaction.object_store(keys::CORE)?.delete(&JsValue::from_str(key)).build()?; - transaction.commit().await?; - Ok(()) + self.with_transaction(&[keys::CORE], TransactionMode::Readwrite, async move |transaction| { + transaction.object_store(keys::CORE)?.delete(&JsValue::from_str(key)).build()?; + transaction.commit().await?; + Ok(()) + }) + .await } async fn try_take_leased_lock( @@ -1695,65 +1735,66 @@ impl_crypto_store! { ) -> Result> { // As of 2023-06-23, the code below hasn't been tested yet. let key = JsValue::from_str(key); - let txn = self - .inner - .transaction(keys::LEASE_LOCKS) - .with_mode(TransactionMode::Readwrite) - .build()?; - let object_store = txn.object_store(keys::LEASE_LOCKS)?; - - #[derive(Deserialize, Serialize)] - struct Lease { - holder: String, - expiration: u64, - generation: CrossProcessLockGeneration, - } - - let now: u64 = MilliSecondsSinceUnixEpoch::now().get().into(); - let expiration = now + lease_duration_ms as u64; + self.with_transaction(&[keys::LEASE_LOCKS], TransactionMode::Readwrite, async move |txn| { + let object_store = txn.object_store(keys::LEASE_LOCKS)?; + + #[derive(Deserialize, Serialize)] + struct Lease { + holder: String, + expiration: u64, + generation: CrossProcessLockGeneration, + } - let lease = match object_store.get(&key).await? { - Some(entry) => { - let mut lease: Lease = self.serializer.deserialize_value(entry)?; + let now: u64 = MilliSecondsSinceUnixEpoch::now().get().into(); + let expiration = now + lease_duration_ms as u64; - if lease.holder == holder { - // We had the lease before, extend it. - lease.expiration = expiration; + let lease = match object_store.get(&key).await? { + Some(entry) => { + let mut lease: Lease = self.serializer.deserialize_value(entry)?; - Some(lease) - } else { - // We didn't have it. - if lease.expiration < now { - // Steal it! - lease.holder = holder.to_owned(); + if lease.holder == holder { + // We had the lease before, extend it. lease.expiration = expiration; - lease.generation += 1; Some(lease) } else { - // We tried our best. - None + // We didn't have it. + if lease.expiration < now { + // Steal it! + lease.holder = holder.to_owned(); + lease.expiration = expiration; + lease.generation += 1; + + Some(lease) + } else { + // We tried our best. + None + } } } - } - None => { - let lease = Lease { - holder: holder.to_owned(), - expiration, - generation: FIRST_CROSS_PROCESS_LOCK_GENERATION, - }; + None => { + let lease = Lease { + holder: holder.to_owned(), + expiration, + generation: FIRST_CROSS_PROCESS_LOCK_GENERATION, + }; - Some(lease) - } - }; + Some(lease) + } + }; - Ok(if let Some(lease) = lease { - object_store.put(&self.serializer.serialize_value(&lease)?).with_key(key).build()?; + Ok(if let Some(lease) = lease { + object_store + .put(&self.serializer.serialize_value(&lease)?) + .with_key(key) + .build()?; - Some(lease.generation) - } else { - None + Some(lease.generation) + } else { + None + }) }) + .await } #[allow(clippy::unused_async)] @@ -1766,9 +1807,10 @@ impl_crypto_store! { Ok(()) } - #[allow(clippy::unused_async)] async fn reopen(&self) -> Result<()> { - Ok(()) + self.connection + .reopen(|name| async move { open_and_upgrade_db(&name, &self.serializer).await }) + .await } } @@ -1776,7 +1818,7 @@ impl Drop for IndexeddbCryptoStore { fn drop(&mut self) { // Must release the database access manually as it's not done when // dropping it. - self.inner.as_sys().close(); + self.connection.database().as_sys().close(); } } From 50ac0e8da33e6ddcb8803eaa41d94a717e5e3bc6 Mon Sep 17 00:00:00 2001 From: Daniel Salinas Date: Thu, 18 Jun 2026 21:19:07 +0000 Subject: [PATCH 4/5] fix(indexeddb): broaden closed-connection detection A connection that dies as a transaction is being built can surface as `TransactionInactiveError` or `AbortError`, not only `InvalidStateError`. Treat all three as "reopen and retry" so more dead-connection cases recover on the next operation instead of erroring once. --- crates/matrix-sdk-indexeddb/src/connection.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/matrix-sdk-indexeddb/src/connection.rs b/crates/matrix-sdk-indexeddb/src/connection.rs index cbe0c78dfa8..b53fe02362c 100644 --- a/crates/matrix-sdk-indexeddb/src/connection.rs +++ b/crates/matrix-sdk-indexeddb/src/connection.rs @@ -132,8 +132,16 @@ impl IndexeddbConnection { /// otherwise healthy connection. /// /// Building a transaction on a closed `IDBDatabase` raises a DOM -/// `InvalidStateError`; that is the signal that the handle must be reopened -/// before retrying. +/// `InvalidStateError`; a connection that dies as the transaction is being set +/// up can also surface as `TransactionInactiveError` or `AbortError`. Any of +/// these means the handle must be reopened before retrying. pub fn is_connection_closed(error: &Error) -> bool { - matches!(error, Error::DomException(DomException::InvalidStateError(_))) + matches!( + error, + Error::DomException( + DomException::InvalidStateError(_) + | DomException::TransactionInactiveError(_) + | DomException::AbortError(_) + ) + ) } From 51bb961b87af58baf5c3e4268c496bb0401a00c8 Mon Sep 17 00:00:00 2001 From: Daniel Salinas Date: Tue, 23 Jun 2026 20:35:37 +0000 Subject: [PATCH 5/5] test(indexeddb): verify connection reopens after the browser closes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wasm-bindgen browser tests exercising the connection-reopen path on a real headless browser: - raw_transaction_fails_after_browser_closes_connection: closes the underlying IDBDatabase (what the browser does) and asserts the next raw transaction build fails with the DOM error is_connection_closed keys on — proving the failure mode is real. - store_operation_recovers_after_browser_closes_connection: issues a store operation after that close and asserts it succeeds (reopen + retry), then survives a second close. Guards the shared IndexeddbConnection reopen mechanism against regressions. --- .../src/event_cache_store/mod.rs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs b/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs index 317414d38bf..3bfde3af186 100644 --- a/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs @@ -748,4 +748,86 @@ mod tests { indexeddb_event_cache_store_integration_tests!(); } + + /// Regression tests for the connection-reopen behaviour: when the browser + /// closes the underlying `IDBDatabase` out from under us, the store must + /// transparently reopen and keep working instead of bricking until restart. + mod reopen_on_closed_connection { + use indexed_db_futures::{Build, internals::SystemRepr, transaction::TransactionMode}; + use matrix_sdk_base::event_cache::store::EventCacheStore; + + use super::*; + use crate::event_cache_store::migrations::current::keys; + + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + async fn get_event_cache_store() -> Result { + let name = format!("test-event-cache-store-{}", Uuid::new_v4().as_hyphenated()); + Ok(IndexeddbEventCacheStore::builder().database_name(name).build().await?) + } + + /// Closing the live handle makes the next *raw* transaction build fail + /// with a DOM `InvalidStateError` — this is the exact failure mode the + /// fix recovers from. Asserting it here proves the simulation is real + /// and not a no-op. + #[wasm_bindgen_test::wasm_bindgen_test] + async fn raw_transaction_fails_after_browser_closes_connection() { + let store = get_event_cache_store().await.unwrap(); + + // Sanity: a transaction builds fine on the live connection. + let db = store.connection.database(); + let _live = db + .transaction([keys::LEASES]) + .with_mode(TransactionMode::Readwrite) + .build() + .expect("transaction builds on a live connection"); + + // Simulate the browser closing the connection. + db.as_sys().close(); + + // The same handle is now permanently unusable. + let err = db + .transaction([keys::LEASES]) + .with_mode(TransactionMode::Readwrite) + .build() + .expect_err("transaction must fail on a closed connection"); + assert!( + crate::connection::is_connection_closed(&err), + "expected a closed-connection DOM error, got: {err:?}" + ); + } + + /// With the fix, a store operation issued *after* the browser closed the + /// connection still succeeds: `with_transaction` detects the closed + /// handle, reopens, and retries. Without the fix this call errors and + /// every subsequent operation keeps failing. + #[wasm_bindgen_test::wasm_bindgen_test] + async fn store_operation_recovers_after_browser_closes_connection() { + let store = get_event_cache_store().await.unwrap(); + + // Baseline: the lock is acquired on the healthy connection. + store + .try_take_leased_lock(0, "key", "alice") + .await + .expect("baseline lease on a live connection"); + + // The browser closes the connection out from under us. + store.connection.database().as_sys().close(); + + // This operation would fail without reopen-and-retry; it must now + // succeed (re-acquiring the lock the same holder already held). + let acquired = store + .try_take_leased_lock(0, "key", "alice") + .await + .expect("store must reopen the closed connection and recover"); + assert!(acquired.is_some(), "expected to re-acquire the lease after reopen"); + + // Recovery is repeatable: close again and the store still works. + store.connection.database().as_sys().close(); + store + .try_take_leased_lock(0, "key", "alice") + .await + .expect("store must recover from a second close"); + } + } }