diff --git a/crates/matrix-sdk-indexeddb/src/event_cache_store/builder.rs b/crates/matrix-sdk-indexeddb/src/event_cache_store/builder.rs new file mode 100644 index 00000000000..87b52e17f7a --- /dev/null +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/builder.rs @@ -0,0 +1,76 @@ +// 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 + +use std::sync::Arc; + +use matrix_sdk_base::event_cache::store::MemoryStore; +use matrix_sdk_store_encryption::StoreCipher; +use web_sys::DomException; + +use crate::{ + event_cache_store::{ + error::IndexeddbEventCacheStoreError, migrations::open_and_upgrade_db, + serializer::IndexeddbEventCacheStoreSerializer, IndexeddbEventCacheStore, + }, + serializer::IndexeddbSerializer, +}; + +/// A type for conveniently building an [`IndexeddbEventCacheStore`] +pub struct IndexeddbEventCacheStoreBuilder { + // The name of the IndexedDB database which will be opened + database_name: String, + // The store cipher, if any, to use when encrypting data + // before it is persisted to the IndexedDB database + store_cipher: Option>, +} + +impl Default for IndexeddbEventCacheStoreBuilder { + fn default() -> Self { + Self { database_name: Self::DEFAULT_DATABASE_NAME.to_owned(), store_cipher: None } + } +} + +impl IndexeddbEventCacheStoreBuilder { + /// The default name of the IndexedDB database used to back the + /// [`IndexeddbEventCacheStore`] + pub const DEFAULT_DATABASE_NAME: &'static str = "event_cache"; + + /// Sets the name of the IndexedDB database which will be opened. This + /// defaults to [`Self::DEFAULT_DATABASE_NAME`]. + pub fn database_name(mut self, name: String) -> Self { + self.database_name = name; + self + } + + /// Sets the store cipher to use when encrypting data before it is persisted + /// to the IndexedDB database. By default, no store cipher is used - + /// i.e., data is not encrypted before it is persisted. + pub fn store_cipher(mut self, store_cipher: Arc) -> Self { + self.store_cipher = Some(store_cipher); + self + } + + /// Opens the IndexedDB database with the provided name. If successfully + /// opened, builds the [`IndexeddbEventCacheStore`] with that database + /// and the provided store cipher. + pub async fn build(self) -> Result { + Ok(IndexeddbEventCacheStore { + inner: open_and_upgrade_db(&self.database_name).await?, + serializer: IndexeddbEventCacheStoreSerializer::new(IndexeddbSerializer::new( + self.store_cipher, + )), + memory_store: MemoryStore::new(), + }) + } +} 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 b298f10d3ac..3c468b0a5da 100644 --- a/crates/matrix-sdk-indexeddb/src/event_cache_store/error.rs +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/error.rs @@ -12,7 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License -use matrix_sdk_base::{SendOutsideWasm, SyncOutsideWasm}; +use matrix_sdk_base::{ + event_cache::store::{EventCacheStore, EventCacheStoreError, MemoryStore}, + SendOutsideWasm, SyncOutsideWasm, +}; +use serde::de::Error; +use thiserror::Error; + +use crate::event_cache_store::transaction::IndexeddbEventCacheStoreTransactionError; /// A trait that combines the necessary traits needed for asynchronous runtimes, /// but excludes them when running in a web environment - i.e., when @@ -21,3 +28,46 @@ pub trait AsyncErrorDeps: std::error::Error + SendOutsideWasm + SyncOutsideWasm impl AsyncErrorDeps for T where T: std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static {} + +#[derive(Debug, Error)] +pub enum IndexeddbEventCacheStoreError { + #[error("DomException {name} ({code}): {message}")] + DomException { name: String, message: String, code: u16 }, + #[error("transaction: {0}")] + Transaction(#[from] IndexeddbEventCacheStoreTransactionError), + #[error("media store: {0}")] + MemoryStore(::Error), +} + +impl From for IndexeddbEventCacheStoreError { + fn from(value: web_sys::DomException) -> IndexeddbEventCacheStoreError { + IndexeddbEventCacheStoreError::DomException { + name: value.name(), + message: value.message(), + code: value.code(), + } + } +} + +impl From for EventCacheStoreError { + fn from(value: IndexeddbEventCacheStoreError) -> Self { + match value { + IndexeddbEventCacheStoreError::DomException { .. } => { + Self::InvalidData { details: value.to_string() } + } + IndexeddbEventCacheStoreError::Transaction(ref inner) => match inner { + IndexeddbEventCacheStoreTransactionError::DomException { .. } => { + Self::InvalidData { details: value.to_string() } + } + IndexeddbEventCacheStoreTransactionError::Serialization(e) => { + Self::Serialization(serde_json::Error::custom(e.to_string())) + } + IndexeddbEventCacheStoreTransactionError::ItemIsNotUnique + | IndexeddbEventCacheStoreTransactionError::ItemNotFound => { + Self::InvalidData { details: value.to_string() } + } + }, + IndexeddbEventCacheStoreError::MemoryStore(inner) => inner, + } + } +} diff --git a/crates/matrix-sdk-indexeddb/src/event_cache_store/integration_tests.rs b/crates/matrix-sdk-indexeddb/src/event_cache_store/integration_tests.rs new file mode 100644 index 00000000000..78a0eef8015 --- /dev/null +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/integration_tests.rs @@ -0,0 +1,598 @@ +// 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 + +use assert_matches::assert_matches; +use matrix_sdk_base::{ + event_cache::{ + store::{ + integration_tests::{check_test_event, make_test_event}, + EventCacheStore, + }, + Gap, + }, + linked_chunk::{ChunkContent, ChunkIdentifier, LinkedChunkId, Position, Update}, +}; +use matrix_sdk_test::DEFAULT_TEST_ROOM_ID; +use ruma::room_id; + +use crate::event_cache_store::{ + transaction::IndexeddbEventCacheStoreTransactionError, IndexeddbEventCacheStore, + IndexeddbEventCacheStoreError, +}; + +pub async fn test_linked_chunk_new_items_chunk(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![ + Update::NewItemsChunk { + previous: None, + new: ChunkIdentifier::new(42), + next: None, // Note: the store must link the next entry itself. + }, + Update::NewItemsChunk { + previous: Some(ChunkIdentifier::new(42)), + new: ChunkIdentifier::new(13), + next: Some(ChunkIdentifier::new(37)), /* But it's fine to explicitly pass + * the next link ahead of time. */ + }, + Update::NewItemsChunk { + previous: Some(ChunkIdentifier::new(13)), + new: ChunkIdentifier::new(37), + next: None, + }, + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 3); + + // Chunks are ordered from smaller to bigger IDs. + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(13)); + assert_eq!(c.previous, Some(ChunkIdentifier::new(42))); + assert_eq!(c.next, Some(ChunkIdentifier::new(37))); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert!(events.is_empty()); + }); + + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(37)); + assert_eq!(c.previous, Some(ChunkIdentifier::new(13))); + assert_eq!(c.next, None); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert!(events.is_empty()); + }); + + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(42)); + assert_eq!(c.previous, None); + assert_eq!(c.next, Some(ChunkIdentifier::new(13))); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert!(events.is_empty()); + }); +} + +pub async fn test_add_gap_chunk_and_delete_it_immediately(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![Update::NewGapChunk { + previous: None, + new: ChunkIdentifier::new(1), + next: None, + gap: Gap { prev_token: "cheese".to_owned() }, + }]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let updates = vec![ + Update::NewGapChunk { + previous: Some(ChunkIdentifier::new(1)), + new: ChunkIdentifier::new(3), + next: None, + gap: Gap { prev_token: "milk".to_owned() }, + }, + Update::RemoveChunk(ChunkIdentifier::new(3)), + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 1); +} + +pub async fn test_linked_chunk_new_gap_chunk(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![Update::NewGapChunk { + previous: None, + new: ChunkIdentifier::new(42), + next: None, + gap: Gap { prev_token: "raclette".to_owned() }, + }]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 1); + + // Chunks are ordered from smaller to bigger IDs. + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(42)); + assert_eq!(c.previous, None); + assert_eq!(c.next, None); + assert_matches!(c.content, ChunkContent::Gap(gap) => { + assert_eq!(gap.prev_token, "raclette"); + }); +} + +pub async fn test_linked_chunk_replace_item(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 0), + items: vec![make_test_event(room_id, "hello"), make_test_event(room_id, "world")], + }, + Update::ReplaceItem { + at: Position::new(ChunkIdentifier::new(42), 1), + item: make_test_event(room_id, "yolo"), + }, + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 1); + + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(42)); + assert_eq!(c.previous, None); + assert_eq!(c.next, None); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert_eq!(events.len(), 2); + check_test_event(&events[0], "hello"); + check_test_event(&events[1], "yolo"); + }); +} + +pub async fn test_linked_chunk_remove_chunk(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![ + Update::NewGapChunk { + previous: None, + new: ChunkIdentifier::new(42), + next: None, + gap: Gap { prev_token: "raclette".to_owned() }, + }, + Update::NewGapChunk { + previous: Some(ChunkIdentifier::new(42)), + new: ChunkIdentifier::new(43), + next: None, + gap: Gap { prev_token: "fondue".to_owned() }, + }, + Update::NewGapChunk { + previous: Some(ChunkIdentifier::new(43)), + new: ChunkIdentifier::new(44), + next: None, + gap: Gap { prev_token: "tartiflette".to_owned() }, + }, + Update::RemoveChunk(ChunkIdentifier::new(43)), + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 2); + + // Chunks are ordered from smaller to bigger IDs. + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(42)); + assert_eq!(c.previous, None); + assert_eq!(c.next, Some(ChunkIdentifier::new(44))); + assert_matches!(c.content, ChunkContent::Gap(gap) => { + assert_eq!(gap.prev_token, "raclette"); + }); + + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(44)); + assert_eq!(c.previous, Some(ChunkIdentifier::new(42))); + assert_eq!(c.next, None); + assert_matches!(c.content, ChunkContent::Gap(gap) => { + assert_eq!(gap.prev_token, "tartiflette"); + }); +} + +pub async fn test_linked_chunk_push_items(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 0), + items: vec![make_test_event(room_id, "hello"), make_test_event(room_id, "world")], + }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 2), + items: vec![make_test_event(room_id, "who?")], + }, + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 1); + + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(42)); + assert_eq!(c.previous, None); + assert_eq!(c.next, None); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert_eq!(events.len(), 3); + check_test_event(&events[0], "hello"); + check_test_event(&events[1], "world"); + check_test_event(&events[2], "who?"); + }); +} + +pub async fn test_linked_chunk_remove_item(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 0), + items: vec![make_test_event(room_id, "hello"), make_test_event(room_id, "world")], + }, + Update::RemoveItem { at: Position::new(ChunkIdentifier::new(42), 0) }, + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 1); + + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(42)); + assert_eq!(c.previous, None); + assert_eq!(c.next, None); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert_eq!(events.len(), 1); + check_test_event(&events[0], "world"); + }); +} + +pub async fn test_linked_chunk_detach_last_items(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 0), + items: vec![ + make_test_event(room_id, "hello"), + make_test_event(room_id, "world"), + make_test_event(room_id, "howdy"), + ], + }, + Update::DetachLastItems { at: Position::new(ChunkIdentifier::new(42), 1) }, + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 1); + + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(42)); + assert_eq!(c.previous, None); + assert_eq!(c.next, None); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert_eq!(events.len(), 1); + check_test_event(&events[0], "hello"); + }); +} + +pub async fn test_linked_chunk_start_end_reattach_items(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + // Same updates and checks as test_linked_chunk_push_items, but with extra + // `StartReattachItems` and `EndReattachItems` updates, which must have no + // effects. + let updates = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 0), + items: vec![ + make_test_event(room_id, "hello"), + make_test_event(room_id, "world"), + make_test_event(room_id, "howdy"), + ], + }, + Update::StartReattachItems, + Update::EndReattachItems, + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert_eq!(chunks.len(), 1); + + let c = chunks.remove(0); + assert_eq!(c.identifier, ChunkIdentifier::new(42)); + assert_eq!(c.previous, None); + assert_eq!(c.next, None); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert_eq!(events.len(), 3); + check_test_event(&events[0], "hello"); + check_test_event(&events[1], "world"); + check_test_event(&events[2], "howdy"); + }); +} + +pub async fn test_linked_chunk_clear(store: IndexeddbEventCacheStore) { + let room_id = &DEFAULT_TEST_ROOM_ID; + let linked_chunk_id = LinkedChunkId::Room(room_id); + let updates = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::NewGapChunk { + previous: Some(ChunkIdentifier::new(42)), + new: ChunkIdentifier::new(54), + next: None, + gap: Gap { prev_token: "fondue".to_owned() }, + }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 0), + items: vec![ + make_test_event(room_id, "hello"), + make_test_event(room_id, "world"), + make_test_event(room_id, "howdy"), + ], + }, + Update::Clear, + ]; + store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap(); + + let chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert!(chunks.is_empty()); +} + +pub async fn test_linked_chunk_multiple_rooms(store: IndexeddbEventCacheStore) { + // Check that applying updates to one room doesn't affect the others. + // Use the same chunk identifier in both rooms to battle-test search. + let room_id1 = room_id!("!realcheeselovers:raclette.fr"); + let linked_chunk_id1 = LinkedChunkId::Room(room_id1); + let updates1 = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 0), + items: vec![ + make_test_event(room_id1, "best cheese is raclette"), + make_test_event(room_id1, "obviously"), + ], + }, + ]; + store.handle_linked_chunk_updates(linked_chunk_id1, updates1).await.unwrap(); + + let room_id2 = room_id!("!realcheeselovers:fondue.ch"); + let linked_chunk_id2 = LinkedChunkId::Room(room_id2); + let updates2 = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::PushItems { + at: Position::new(ChunkIdentifier::new(42), 0), + items: vec![make_test_event(room_id2, "beaufort is the best")], + }, + ]; + store.handle_linked_chunk_updates(linked_chunk_id2, updates2).await.unwrap(); + + // Check chunks from room 1. + let mut chunks1 = store.load_all_chunks(linked_chunk_id1).await.unwrap(); + assert_eq!(chunks1.len(), 1); + + let c = chunks1.remove(0); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert_eq!(events.len(), 2); + check_test_event(&events[0], "best cheese is raclette"); + check_test_event(&events[1], "obviously"); + }); + + // Check chunks from room 2. + let mut chunks2 = store.load_all_chunks(linked_chunk_id2).await.unwrap(); + assert_eq!(chunks2.len(), 1); + + let c = chunks2.remove(0); + assert_matches!(c.content, ChunkContent::Items(events) => { + assert_eq!(events.len(), 1); + check_test_event(&events[0], "beaufort is the best"); + }); +} + +pub async fn test_linked_chunk_update_is_a_transaction(store: IndexeddbEventCacheStore) { + let linked_chunk_id = LinkedChunkId::Room(*DEFAULT_TEST_ROOM_ID); + // Trigger a violation of the unique constraint on the (room id, chunk id) + // couple. + let updates = vec![ + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None }, + ]; + let err = store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap_err(); + + // The operation fails with a constraint violation error. + assert_matches!( + err, + IndexeddbEventCacheStoreError::Transaction( + IndexeddbEventCacheStoreTransactionError::DomException { .. } + ) + ); + + // If the updates have been handled transactionally, then no new chunks should + // have been added; failure of the second update leads to the first one being + // rolled back. + let chunks = store.load_all_chunks(linked_chunk_id).await.unwrap(); + assert!(chunks.is_empty()); +} + +/// Macro for generating tests for IndexedDB implementation of +/// [`EventCacheStore`] +/// +/// The enclosing module must provide a function for constructing an +/// [`EventCacheStore`] which will be used in the generated tests. The function +/// must have the signature shown in the example below. +/// +/// +/// ## Usage Example: +/// ```no_run +/// # use matrix_sdk_base::event_cache::store::{ +/// # EventCacheStore, +/// # EventCacheStoreError, +/// # MemoryStore as MyStore, +/// # }; +/// +/// #[cfg(test)] +/// mod tests { +/// use super::{EventCacheStore, EventCacheStoreResult, MyStore}; +/// +/// async fn get_event_cache_store( +/// ) -> Result { +/// Ok(MyStore::new()) +/// } +/// +/// event_cache_store_integration_tests!(); +/// } +/// ``` +#[macro_export] +macro_rules! indexeddb_event_cache_store_integration_tests { + () => { + mod indexeddb_event_cache_store_integration_tests { + use matrix_sdk_test::async_test; + + use super::get_event_cache_store; + + #[async_test] + async fn test_linked_chunk_new_items_chunk() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_new_items_chunk(store).await + } + + #[async_test] + async fn test_add_gap_chunk_and_delete_it_immediately() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_add_gap_chunk_and_delete_it_immediately( + store, + ) + .await + } + + #[async_test] + async fn test_linked_chunk_new_gap_chunk() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_new_gap_chunk(store).await + } + + #[async_test] + async fn test_linked_chunk_replace_item() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_replace_item(store).await + } + + #[async_test] + async fn test_linked_chunk_remove_chunk() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_remove_chunk(store).await + } + + #[async_test] + async fn test_linked_chunk_push_items() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_push_items(store).await + } + + #[async_test] + async fn test_linked_chunk_remove_item() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_remove_item(store).await + } + + #[async_test] + async fn test_linked_chunk_detach_last_items() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_detach_last_items(store).await + } + + #[async_test] + async fn test_linked_chunk_start_end_reattach_items() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_start_end_reattach_items(store) + .await + } + + #[async_test] + async fn test_linked_chunk_clear() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_clear(store).await + } + + #[async_test] + async fn test_linked_chunk_multiple_rooms() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_multiple_rooms(store).await + } + + #[async_test] + async fn test_linked_chunk_update_is_a_transaction() { + let store = get_event_cache_store().await.expect("Failed to get event cache store"); + $crate::event_cache_store::integration_tests::test_linked_chunk_update_is_a_transaction(store) + .await + } + } + }; +} + +// This is copied from `matrix_sdk_base::event_cache::store::integration_tests` +// for the time being, because the IndexedDB implementation of `EventCacheStore` +// is being completed iteratively. So, we are only bringing over the tests +// relevant to the implemented functions. At the moment, this includes the +// following. +// +// - EventCacheStore::handle_linked_chunk_updates +// - EventCacheStore::load_all_chunks +// +// When all functions are implemented, we can get rid of this macro and use the +// one from `matrix_sdk_base`. +#[macro_export] +macro_rules! event_cache_store_integration_tests { + () => { + mod event_cache_store_integration_tests { + use matrix_sdk_base::event_cache::store::{ + EventCacheStoreIntegrationTests, IntoEventCacheStore, + }; + use matrix_sdk_test::async_test; + + use super::get_event_cache_store; + + #[async_test] + async fn test_handle_updates_and_rebuild_linked_chunk() { + let event_cache_store = + get_event_cache_store().await.unwrap().into_event_cache_store(); + event_cache_store.test_handle_updates_and_rebuild_linked_chunk().await; + } + + #[async_test] + async fn test_rebuild_empty_linked_chunk() { + let event_cache_store = + get_event_cache_store().await.unwrap().into_event_cache_store(); + event_cache_store.test_rebuild_empty_linked_chunk().await; + } + + #[async_test] + async fn test_remove_room() { + let event_cache_store = + get_event_cache_store().await.unwrap().into_event_cache_store(); + event_cache_store.test_remove_room().await; + } + } + }; +} 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 b9e249e7351..42939f2c9e0 100644 --- a/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/mod.rs @@ -14,8 +14,499 @@ #![allow(unused)] +use indexed_db_futures::IdbDatabase; +use matrix_sdk_base::{ + event_cache::{ + store::{ + media::{IgnoreMediaRetentionPolicy, MediaRetentionPolicy}, + EventCacheStore, MemoryStore, + }, + Event, Gap, + }, + linked_chunk::{ + ChunkIdentifier, ChunkIdentifierGenerator, ChunkMetadata, LinkedChunkId, Position, + RawChunk, Update, + }, + media::MediaRequestParameters, +}; +use ruma::{events::relation::RelationType, EventId, MxcUri, OwnedEventId, RoomId}; +use tracing::trace; +use web_sys::IdbTransactionMode; + +use crate::event_cache_store::{ + migrations::current::keys, + serializer::IndexeddbEventCacheStoreSerializer, + transaction::IndexeddbEventCacheStoreTransaction, + types::{ChunkType, InBandEvent}, +}; + +mod builder; mod error; +#[cfg(test)] +mod integration_tests; mod migrations; mod serializer; mod transaction; mod types; + +pub use builder::IndexeddbEventCacheStoreBuilder; +pub use error::IndexeddbEventCacheStoreError; + +/// A type for providing an IndexedDB implementation of [`EventCacheStore`][1]. +/// This is meant to be used as a backend to [`EventCacheStore`][1] in browser +/// contexts. +/// +/// [1]: matrix_sdk_base::event_cache::store::EventCacheStore +#[derive(Debug)] +pub struct IndexeddbEventCacheStore { + // A handle to the IndexedDB database + inner: IdbDatabase, + // A serializer with functionality tailored to `IndexeddbEventCacheStore` + serializer: IndexeddbEventCacheStoreSerializer, + // An in-memory store for providing temporary implementations for + // functions of `EventCacheStore`. + // + // NOTE: This will be removed once we have IndexedDB-backed implementations for all + // functions in `EventCacheStore`. + memory_store: MemoryStore, +} + +impl IndexeddbEventCacheStore { + /// Provides a type with which to conveniently build an + /// [`IndexeddbEventCacheStore`] + pub fn builder() -> IndexeddbEventCacheStoreBuilder { + 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, + stores: &[&str], + mode: IdbTransactionMode, + ) -> Result, IndexeddbEventCacheStoreError> { + Ok(IndexeddbEventCacheStoreTransaction::new( + self.inner.transaction_on_multi_with_mode(stores, mode)?, + &self.serializer, + )) + } +} + +// Small hack to have the following macro invocation act as the appropriate +// trait impl block on wasm, but still be compiled on non-wasm as a regular +// impl block otherwise. +// +// The trait impl doesn't compile on non-wasm due to unfulfilled trait bounds, +// this hack allows us to still have most of rust-analyzer's IDE functionality +// within the impl block without having to set it up to check things against +// the wasm target (which would disable many other parts of the codebase). +#[cfg(target_arch = "wasm32")] +macro_rules! impl_event_cache_store { + ( $($body:tt)* ) => { + #[async_trait::async_trait(?Send)] + impl EventCacheStore for IndexeddbEventCacheStore { + type Error = IndexeddbEventCacheStoreError; + + $($body)* + } + }; +} + +#[cfg(not(target_arch = "wasm32"))] +macro_rules! impl_event_cache_store { + ( $($body:tt)* ) => { + impl IndexeddbEventCacheStore { + $($body)* + } + }; +} + +impl_event_cache_store! { + async fn try_take_leased_lock( + &self, + lease_duration_ms: u32, + key: &str, + holder: &str, + ) -> Result { + self.memory_store + .try_take_leased_lock(lease_duration_ms, key, holder) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn handle_linked_chunk_updates( + &self, + linked_chunk_id: LinkedChunkId<'_>, + updates: Vec>, + ) -> Result<(), IndexeddbEventCacheStoreError> { + let linked_chunk_id = linked_chunk_id.to_owned(); + let room_id = linked_chunk_id.room_id(); + + let transaction = self.transaction( + &[keys::LINKED_CHUNKS, keys::GAPS, keys::EVENTS], + IdbTransactionMode::Readwrite, + )?; + + for update in updates { + match update { + Update::NewItemsChunk { previous, new, next } => { + trace!(%room_id, "Inserting new chunk (prev={previous:?}, new={new:?}, next={next:?})"); + transaction + .add_chunk( + room_id, + &types::Chunk { + identifier: new.index(), + previous: previous.map(|i| i.index()), + next: next.map(|i| i.index()), + chunk_type: ChunkType::Event, + }, + ) + .await?; + } + Update::NewGapChunk { previous, new, next, gap } => { + trace!(%room_id, "Inserting new gap (prev={previous:?}, new={new:?}, next={next:?})"); + transaction + .add_item( + room_id, + &types::Gap { + chunk_identifier: new.index(), + prev_token: gap.prev_token, + }, + ) + .await?; + transaction + .add_chunk( + room_id, + &types::Chunk { + identifier: new.index(), + previous: previous.map(|i| i.index()), + next: next.map(|i| i.index()), + chunk_type: ChunkType::Gap, + }, + ) + .await?; + } + Update::RemoveChunk(chunk_id) => { + trace!("Removing chunk {chunk_id:?}"); + transaction.delete_chunk_by_id(room_id, &chunk_id).await?; + } + Update::PushItems { at, items } => { + let chunk_identifier = at.chunk_identifier().index(); + + trace!(%room_id, "pushing {} items @ {chunk_identifier}", items.len()); + + for (i, item) in items.into_iter().enumerate() { + transaction + .add_item( + room_id, + &types::Event::InBand(InBandEvent { + content: item, + position: types::Position { + chunk_identifier, + index: at.index() + i, + }, + }), + ) + .await?; + } + } + Update::ReplaceItem { at, item } => { + let chunk_id = at.chunk_identifier().index(); + let index = at.index(); + + trace!(%room_id, "replacing item @ {chunk_id}:{index}"); + + transaction + .put_event( + room_id, + &types::Event::InBand(InBandEvent { + content: item, + position: at.into(), + }), + ) + .await?; + } + Update::RemoveItem { at } => { + let chunk_id = at.chunk_identifier().index(); + let index = at.index(); + + trace!(%room_id, "removing item @ {chunk_id}:{index}"); + + transaction.delete_event_by_position(room_id, &at.into()).await?; + } + Update::DetachLastItems { at } => { + let chunk_id = at.chunk_identifier().index(); + let index = at.index(); + + trace!(%room_id, "detaching last items @ {chunk_id}:{index}"); + + transaction.delete_events_by_chunk_from_index(room_id, &at.into()).await?; + } + Update::StartReattachItems | Update::EndReattachItems => { + // Nothing? See sqlite implementation + } + Update::Clear => { + trace!(%room_id, "clearing room"); + transaction.delete_chunks_in_room(room_id).await?; + transaction.delete_events_in_room(room_id).await?; + transaction.delete_gaps_in_room(room_id).await?; + } + } + } + transaction.commit().await?; + Ok(()) + } + + async fn load_all_chunks( + &self, + linked_chunk_id: LinkedChunkId<'_>, + ) -> Result>, IndexeddbEventCacheStoreError> { + let linked_chunk_id = linked_chunk_id.to_owned(); + let room_id = linked_chunk_id.room_id(); + + let transaction = self.transaction( + &[keys::LINKED_CHUNKS, keys::GAPS, keys::EVENTS], + IdbTransactionMode::Readwrite, + )?; + + let mut raw_chunks = Vec::new(); + let chunks = transaction.get_chunks_in_room(room_id).await?; + for chunk in chunks { + if let Some(raw_chunk) = transaction + .load_chunk_by_id(room_id, &ChunkIdentifier::new(chunk.identifier)) + .await? + { + raw_chunks.push(raw_chunk); + } + } + Ok(raw_chunks) + } + + async fn load_all_chunks_metadata( + &self, + linked_chunk_id: LinkedChunkId<'_>, + ) -> Result, IndexeddbEventCacheStoreError> { + self.memory_store + .load_all_chunks_metadata(linked_chunk_id) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn load_last_chunk( + &self, + linked_chunk_id: LinkedChunkId<'_>, + ) -> Result< + (Option>, ChunkIdentifierGenerator), + IndexeddbEventCacheStoreError, + > { + self.memory_store + .load_last_chunk(linked_chunk_id) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn load_previous_chunk( + &self, + linked_chunk_id: LinkedChunkId<'_>, + before_chunk_identifier: ChunkIdentifier, + ) -> Result>, IndexeddbEventCacheStoreError> { + self.memory_store + .load_previous_chunk(linked_chunk_id, before_chunk_identifier) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn clear_all_linked_chunks(&self) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .clear_all_linked_chunks() + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn filter_duplicated_events( + &self, + linked_chunk_id: LinkedChunkId<'_>, + events: Vec, + ) -> Result, IndexeddbEventCacheStoreError> { + self.memory_store + .filter_duplicated_events(linked_chunk_id, events) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn find_event( + &self, + room_id: &RoomId, + event_id: &EventId, + ) -> Result, IndexeddbEventCacheStoreError> { + self.memory_store + .find_event(room_id, event_id) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn find_event_relations( + &self, + room_id: &RoomId, + event_id: &EventId, + filters: Option<&[RelationType]>, + ) -> Result)>, IndexeddbEventCacheStoreError> { + self.memory_store + .find_event_relations(room_id, event_id, filters) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn save_event( + &self, + room_id: &RoomId, + event: Event, + ) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .save_event(room_id, event) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn add_media_content( + &self, + request: &MediaRequestParameters, + content: Vec, + ignore_policy: IgnoreMediaRetentionPolicy, + ) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .add_media_content(request, content, ignore_policy) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn replace_media_key( + &self, + from: &MediaRequestParameters, + to: &MediaRequestParameters, + ) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .replace_media_key(from, to) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn get_media_content( + &self, + request: &MediaRequestParameters, + ) -> Result>, IndexeddbEventCacheStoreError> { + self.memory_store + .get_media_content(request) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn remove_media_content( + &self, + request: &MediaRequestParameters, + ) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .remove_media_content(request) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn get_media_content_for_uri( + &self, + uri: &MxcUri, + ) -> Result>, IndexeddbEventCacheStoreError> { + self.memory_store + .get_media_content_for_uri(uri) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn remove_media_content_for_uri( + &self, + uri: &MxcUri, + ) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .remove_media_content_for_uri(uri) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn set_media_retention_policy( + &self, + policy: MediaRetentionPolicy, + ) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .set_media_retention_policy(policy) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + fn media_retention_policy(&self) -> MediaRetentionPolicy { + self.memory_store.media_retention_policy() + } + + async fn set_ignore_media_retention_policy( + &self, + request: &MediaRequestParameters, + ignore_policy: IgnoreMediaRetentionPolicy, + ) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .set_ignore_media_retention_policy(request, ignore_policy) + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } + + async fn clean_up_media_cache(&self) -> Result<(), IndexeddbEventCacheStoreError> { + self.memory_store + .clean_up_media_cache() + .await + .map_err(IndexeddbEventCacheStoreError::MemoryStore) + } +} + +#[cfg(test)] +mod tests { + use matrix_sdk_base::event_cache::store::{EventCacheStore, EventCacheStoreError}; + use matrix_sdk_test::async_test; + use uuid::Uuid; + + use crate::{ + event_cache_store::IndexeddbEventCacheStore, event_cache_store_integration_tests, + indexeddb_event_cache_store_integration_tests, + }; + + mod unencrypted { + use super::*; + + 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?) + } + + #[cfg(target_family = "wasm")] + event_cache_store_integration_tests!(); + + #[cfg(target_family = "wasm")] + indexeddb_event_cache_store_integration_tests!(); + } + + mod encrypted { + use super::*; + + 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?) + } + + #[cfg(target_family = "wasm")] + event_cache_store_integration_tests!(); + + #[cfg(target_family = "wasm")] + indexeddb_event_cache_store_integration_tests!(); + } +} diff --git a/crates/matrix-sdk-indexeddb/src/event_cache_store/serializer/mod.rs b/crates/matrix-sdk-indexeddb/src/event_cache_store/serializer/mod.rs index 7e9fc81aae6..d429578d5f8 100644 --- a/crates/matrix-sdk-indexeddb/src/event_cache_store/serializer/mod.rs +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/serializer/mod.rs @@ -52,6 +52,7 @@ impl From for IndexeddbEventCacheStoreSerializerEr /// [`EventCacheStore`][1]. /// /// [1]: matrix_sdk_base::event_cache::store::EventCacheStore +#[derive(Debug)] pub struct IndexeddbEventCacheStoreSerializer { inner: IndexeddbSerializer, } diff --git a/crates/matrix-sdk-indexeddb/src/event_cache_store/transaction.rs b/crates/matrix-sdk-indexeddb/src/event_cache_store/transaction.rs index d4ff2648567..00485f4c510 100644 --- a/crates/matrix-sdk-indexeddb/src/event_cache_store/transaction.rs +++ b/crates/matrix-sdk-indexeddb/src/event_cache_store/transaction.rs @@ -43,6 +43,8 @@ pub enum IndexeddbEventCacheStoreTransactionError { Serialization(Box), #[error("item is not unique")] ItemIsNotUnique, + #[error("item not found")] + ItemNotFound, } impl From for IndexeddbEventCacheStoreTransactionError { @@ -393,4 +395,262 @@ impl<'a> IndexeddbEventCacheStoreTransaction<'a> { { self.transaction.object_store(T::OBJECT_STORE)?.clear()?.await.map_err(Into::into) } + + /// Query IndexedDB for chunks that match the given chunk identifier in the + /// given room. If more than one item is found, an error is returned. + pub async fn get_chunk_by_id( + &self, + room_id: &RoomId, + chunk_id: &ChunkIdentifier, + ) -> Result, IndexeddbEventCacheStoreTransactionError> { + self.get_item_by_key_components::(room_id, chunk_id).await + } + + /// Query IndexedDB for all chunks in the given room + pub async fn get_chunks_in_room( + &self, + room_id: &RoomId, + ) -> Result, IndexeddbEventCacheStoreTransactionError> { + self.get_items_in_room::(room_id).await + } + + /// Query IndexedDB for given chunk in given room and additionally query + /// for events or gap, depending on chunk type, in order to construct the + /// full chunk. + pub async fn load_chunk_by_id( + &self, + room_id: &RoomId, + chunk_id: &ChunkIdentifier, + ) -> Result>, IndexeddbEventCacheStoreTransactionError> { + if let Some(chunk) = self.get_chunk_by_id(room_id, chunk_id).await? { + let content = match chunk.chunk_type { + ChunkType::Event => { + let events = self + .get_events_by_chunk(room_id, &ChunkIdentifier::new(chunk.identifier)) + .await? + .into_iter() + .map(RawEvent::from) + .collect(); + ChunkContent::Items(events) + } + ChunkType::Gap => { + let gap = self + .get_gap_by_id(room_id, &ChunkIdentifier::new(chunk.identifier)) + .await? + .ok_or(IndexeddbEventCacheStoreTransactionError::ItemNotFound)?; + ChunkContent::Gap(RawGap { prev_token: gap.prev_token }) + } + }; + return Ok(Some(RawChunk { + identifier: ChunkIdentifier::new(chunk.identifier), + content, + previous: chunk.previous.map(ChunkIdentifier::new), + next: chunk.next.map(ChunkIdentifier::new), + })); + } + Ok(None) + } + + /// Add a chunk to the given room and ensure that the next and previous + /// chunks are properly linked to the chunk being added. If a chunk with + /// the same identifier already exists, the given chunk will be + /// rejected. + pub async fn add_chunk( + &self, + room_id: &RoomId, + chunk: &Chunk, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + self.add_item(room_id, chunk).await?; + if let Some(previous) = chunk.previous { + let previous_identifier = ChunkIdentifier::new(previous); + if let Some(mut previous_chunk) = + self.get_chunk_by_id(room_id, &previous_identifier).await? + { + previous_chunk.next = Some(chunk.identifier); + self.put_item(room_id, &previous_chunk).await?; + } + } + if let Some(next) = chunk.next { + let next_identifier = ChunkIdentifier::new(next); + if let Some(mut next_chunk) = self.get_chunk_by_id(room_id, &next_identifier).await? { + next_chunk.previous = Some(chunk.identifier); + self.put_item(room_id, &next_chunk).await?; + } + } + Ok(()) + } + + /// Delete chunk that matches the given id in the given room and ensure that + /// the next and previous chunk are updated to link to one another. + /// Additionally, ensure that events and gaps in the given chunk are + /// also deleted. + pub async fn delete_chunk_by_id( + &self, + room_id: &RoomId, + chunk_id: &ChunkIdentifier, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + if let Some(chunk) = self.get_chunk_by_id(room_id, chunk_id).await? { + if let Some(previous) = chunk.previous { + let previous_identifier = ChunkIdentifier::new(previous); + if let Some(mut previous_chunk) = + self.get_chunk_by_id(room_id, &previous_identifier).await? + { + previous_chunk.next = chunk.next; + self.put_item(room_id, &previous_chunk).await?; + } + } + if let Some(next) = chunk.next { + let next_identifier = ChunkIdentifier::new(next); + if let Some(mut next_chunk) = + self.get_chunk_by_id(room_id, &next_identifier).await? + { + next_chunk.previous = chunk.previous; + self.put_item(room_id, &next_chunk).await?; + } + } + self.delete_item_by_key::(room_id, chunk_id).await?; + match chunk.chunk_type { + ChunkType::Event => { + self.delete_events_by_chunk(room_id, chunk_id).await?; + } + ChunkType::Gap => { + self.delete_gap_by_id(room_id, chunk_id).await?; + } + } + } + Ok(()) + } + + /// Delete all chunks in the given room + pub async fn delete_chunks_in_room( + &self, + room_id: &RoomId, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + self.delete_items_in_room::(room_id).await + } + + /// Query IndexedDB for events in the given position range in the given + /// room. + pub async fn get_events_by_position( + &self, + room_id: &RoomId, + range: impl Into>, + ) -> Result, IndexeddbEventCacheStoreTransactionError> { + self.get_items_by_key_components::(room_id, range).await + } + + /// Query IndexedDB for events in the given chunk in the given room. + pub async fn get_events_by_chunk( + &self, + room_id: &RoomId, + chunk_id: &ChunkIdentifier, + ) -> Result, IndexeddbEventCacheStoreTransactionError> { + let mut lower = IndexedEventPositionKey::lower_key_components(); + lower.chunk_identifier = chunk_id.index(); + let mut upper = IndexedEventPositionKey::upper_key_components(); + upper.chunk_identifier = chunk_id.index(); + let range = IndexedKeyRange::Bound(&lower, &upper); + self.get_events_by_position(room_id, range).await + } + + /// Puts an event in the given room. If an event with the same key already + /// exists, it will be overwritten. + pub async fn put_event( + &self, + room_id: &RoomId, + event: &Event, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + if let Some(position) = event.position() { + // For some reason, we can't simply replace an event with `put_item` + // because we can get an error stating that the data violates a uniqueness + // constraint on the `events_position` index. This is NOT expected, but + // it is not clear if this improperly implemented in the browser or the + // library we are using. + // + // As a workaround, if the event has a position, we delete it first and + // then call `put_item`. This should be fine as it all happens within the + // context of a single transaction. + self.delete_event_by_position(room_id, &position).await?; + } + self.put_item(room_id, event).await + } + + /// Delete events in the given position range in the given room + pub async fn delete_events_by_position( + &self, + room_id: &RoomId, + range: impl Into>, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + self.delete_items_by_key_components::(room_id, range).await + } + + /// Delete event in the given position in the given room + pub async fn delete_event_by_position( + &self, + room_id: &RoomId, + position: &Position, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + self.delete_item_by_key::(room_id, position).await + } + + /// Delete events in the given chunk in the given room + pub async fn delete_events_by_chunk( + &self, + room_id: &RoomId, + chunk_id: &ChunkIdentifier, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + let mut lower = IndexedEventPositionKey::lower_key_components(); + lower.chunk_identifier = chunk_id.index(); + let mut upper = IndexedEventPositionKey::upper_key_components(); + upper.chunk_identifier = chunk_id.index(); + let range = IndexedKeyRange::Bound(&lower, &upper); + self.delete_events_by_position(room_id, range).await + } + + /// Delete events starting from the given position in the given room + /// until the end of the chunk + pub async fn delete_events_by_chunk_from_index( + &self, + room_id: &RoomId, + position: &Position, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + let mut upper = IndexedEventPositionKey::upper_key_components(); + upper.chunk_identifier = position.chunk_identifier; + let range = IndexedKeyRange::Bound(position, &upper); + self.delete_events_by_position(room_id, range).await + } + + /// Delete all events in the given room + pub async fn delete_events_in_room( + &self, + room_id: &RoomId, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + self.delete_items_in_room::(room_id).await + } + + /// Query IndexedDB for the gap in the given chunk in the given room. + pub async fn get_gap_by_id( + &self, + room_id: &RoomId, + chunk_id: &ChunkIdentifier, + ) -> Result, IndexeddbEventCacheStoreTransactionError> { + self.get_item_by_key_components::(room_id, chunk_id).await + } + + /// Delete gap that matches the given chunk identifier in the given room + pub async fn delete_gap_by_id( + &self, + room_id: &RoomId, + chunk_id: &ChunkIdentifier, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + self.delete_item_by_key::(room_id, chunk_id).await + } + + /// Delete all gaps in the given room + pub async fn delete_gaps_in_room( + &self, + room_id: &RoomId, + ) -> Result<(), IndexeddbEventCacheStoreTransactionError> { + self.delete_items_in_room::(room_id).await + } } diff --git a/crates/matrix-sdk-indexeddb/src/serializer.rs b/crates/matrix-sdk-indexeddb/src/serializer.rs index 46af7c226c8..43fab58088b 100644 --- a/crates/matrix-sdk-indexeddb/src/serializer.rs +++ b/crates/matrix-sdk-indexeddb/src/serializer.rs @@ -39,6 +39,14 @@ pub struct IndexeddbSerializer { store_cipher: Option>, } +impl std::fmt::Debug for IndexeddbSerializer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IndexeddbSerializer") + .field("store_cipher", &self.store_cipher.as_ref().map(|_| "")) + .finish() + } +} + #[derive(Debug, thiserror::Error)] pub enum IndexeddbSerializerError { #[error(transparent)]