1use openmls::group::MlsGroup as OpenMlsGroup;
4use xmtp_db::{
5 NotFound, StorageError, TransactionOutcome, TransactionalKeyStore, XmtpMlsStorageProvider,
6 sql_key_store::SqlKeyStoreError,
7};
8use xmtp_events::{EventBuffer, EventBus};
9use xmtp_proto::types::GroupId;
10
11use crate::events::InternalEvent;
12
13pub struct StateTx<'a, T> {
19 query: &'a mut T,
20}
21
22impl<T: TransactionalKeyStore> StateTx<'_, T> {
23 pub fn with_group<R, E>(
28 &mut self,
29 group_id: GroupId,
30 operation: impl FnOnce(&mut OpenMlsGroup, &T::Store<'_>) -> Result<R, E>,
31 ) -> Result<R, E>
32 where
33 E: From<StorageError> + From<SqlKeyStoreError>,
34 {
35 let storage = self.query.key_store();
36 let mut group = OpenMlsGroup::load(&storage, &group_id.to_openmls())?
37 .ok_or_else(|| StorageError::from(NotFound::MlsGroup(group_id)))?;
38 operation(&mut group, &storage)
39 }
40
41 pub fn storage(&mut self) -> T::Store<'_> {
43 self.query.key_store()
44 }
45
46 pub fn savepoint<R, E>(
51 &mut self,
52 operation: impl FnOnce(
53 &mut StateTx<'_, <T::Store<'_> as XmtpMlsStorageProvider>::TxQuery>,
54 ) -> Result<TransactionOutcome<R>, E>,
55 ) -> Result<TransactionOutcome<R>, E>
56 where
57 E: From<xmtp_db::diesel::result::Error>
58 + From<xmtp_db::ConnectionError>
59 + std::error::Error,
60 {
61 self.query
62 .key_store()
63 .savepoint(|query| operation(&mut StateTx { query }))
64 }
65}
66
67pub fn state_write<S, R, E>(
72 storage: &S,
73 operation: impl FnOnce(&mut StateTx<'_, S::TxQuery>) -> Result<TransactionOutcome<R>, E>,
74) -> Result<TransactionOutcome<R>, E>
75where
76 S: XmtpMlsStorageProvider,
77 E: From<xmtp_db::diesel::result::Error> + From<xmtp_db::ConnectionError> + std::error::Error,
78{
79 storage.transaction(|query| {
80 let outcome = operation(&mut StateTx { query })?;
81 #[cfg(all(any(test, feature = "test-hooks"), not(target_arch = "wasm32")))]
82 if matches!(outcome, TransactionOutcome::Continue(_)) && precommit_test_hook::is_set() {
83 use xmtp_db::ConnectionExt;
84 query.key_store().db().raw_query(|conn| {
85 precommit_test_hook::run(conn);
86 Ok(())
87 })?;
88 }
89 Ok(outcome)
90 })
91}
92
93pub fn state_write_durable<S, R, E>(
95 storage: &S,
96 operation: impl FnOnce(&mut StateTx<'_, S::TxQuery>) -> Result<TransactionOutcome<R>, E>,
97) -> Result<TransactionOutcome<R>, E>
98where
99 S: XmtpMlsStorageProvider,
100 E: From<xmtp_db::diesel::result::Error> + From<xmtp_db::ConnectionError> + std::error::Error,
101{
102 storage.durable_transaction(|query| {
103 let outcome = operation(&mut StateTx { query })?;
104 #[cfg(all(any(test, feature = "test-hooks"), not(target_arch = "wasm32")))]
105 if matches!(outcome, TransactionOutcome::Continue(_)) && precommit_test_hook::is_set() {
106 use xmtp_db::ConnectionExt;
107 query.key_store().db().raw_query(|conn| {
108 precommit_test_hook::run(conn);
109 Ok(())
110 })?;
111 }
112 Ok(outcome)
113 })
114}
115
116pub fn state_write_with_events<S, R, E>(
118 storage: &S,
119 bus: &EventBus<InternalEvent>,
120 operation: impl FnOnce(
121 &mut StateTx<'_, S::TxQuery>,
122 &EventBuffer<'_, InternalEvent>,
123 ) -> Result<TransactionOutcome<R>, E>,
124) -> Result<TransactionOutcome<R>, E>
125where
126 S: XmtpMlsStorageProvider,
127 E: From<xmtp_db::diesel::result::Error> + From<xmtp_db::ConnectionError> + std::error::Error,
128{
129 let mut result = None;
130 let _: Result<(), ()> = bus.with_buffer(|events| {
131 result = Some(state_write(storage, |tx| operation(tx, events)));
132 if matches!(&result, Some(Ok(TransactionOutcome::Continue(_)))) {
133 Ok(())
134 } else {
135 Err(())
136 }
137 });
138 result.expect("state write produced a result")
139}
140
141pub fn state_write_with_events_durable<S, R, E>(
143 storage: &S,
144 bus: &EventBus<InternalEvent>,
145 operation: impl FnOnce(
146 &mut StateTx<'_, S::TxQuery>,
147 &EventBuffer<'_, InternalEvent>,
148 ) -> Result<TransactionOutcome<R>, E>,
149) -> Result<TransactionOutcome<R>, E>
150where
151 S: XmtpMlsStorageProvider,
152 E: From<xmtp_db::diesel::result::Error> + From<xmtp_db::ConnectionError> + std::error::Error,
153{
154 bus.with_buffer(
155 |events| match state_write_durable(storage, |tx| operation(tx, events)) {
156 result @ Ok(TransactionOutcome::Continue(_)) => Ok(result),
157 result => Err(result),
158 },
159 )
160 .unwrap_or_else(|result| result)
161}
162
163#[cfg(all(any(test, feature = "test-hooks"), not(target_arch = "wasm32")))]
166#[doc(hidden)]
167pub mod precommit_test_hook {
168 use std::{cell::RefCell, marker::PhantomData, rc::Rc};
169 use xmtp_db::diesel::SqliteConnection;
170
171 type Hook = Box<dyn FnMut(&mut SqliteConnection)>;
172
173 thread_local! {
174 static HOOK: RefCell<Option<Hook>> = const { RefCell::new(None) };
175 }
176
177 pub struct Guard {
181 previous: Option<Hook>,
182 _same_thread: PhantomData<Rc<()>>,
183 }
184
185 pub fn install(hook: impl FnMut(&mut SqliteConnection) + 'static) -> Guard {
188 Guard {
189 previous: HOOK.with(|slot| slot.replace(Some(Box::new(hook)))),
190 _same_thread: PhantomData,
191 }
192 }
193
194 pub(super) fn is_set() -> bool {
195 HOOK.with(|slot| slot.borrow().is_some())
196 }
197
198 pub(super) fn run(conn: &mut SqliteConnection) {
199 HOOK.with(|slot| {
200 if let Some(hook) = slot.borrow_mut().as_mut() {
201 hook(conn);
202 }
203 });
204 }
205
206 impl Drop for Guard {
207 fn drop(&mut self) {
208 HOOK.with(|slot| {
209 slot.replace(self.previous.take());
210 });
211 }
212 }
213}
214
215#[cfg(test)]
216mod event_tests {
217 use super::*;
218 use crate::{context::CoreContext, events::emit_consent_changes, test_context::real_context};
219 use xmtp_db::{
220 consent_record::{ConsentKey, ConsentState, ConsentWrite, Decision, Owed, write_consent},
221 prelude::*,
222 };
223 use xmtp_events::{ClientEvent, EventFilter, EventKind};
224
225 #[xmtp_common::test(unwrap_try = true)]
227 async fn event_write_flushes_after_commit_and_clears_on_rollback() {
228 let context = real_context().await;
229 let bus = context.events();
230 let subscription = bus.subscribe(EventFilter::new([EventKind::ConsentChanged]), Some(10));
231 #[cfg(not(target_arch = "wasm32"))]
232 let before_commit = bus.subscribe(EventFilter::new([EventKind::ConsentChanged]), Some(10));
233 let committed = ConsentKey::inbox("committed");
234 #[cfg(not(target_arch = "wasm32"))]
235 let fired = std::rc::Rc::new(std::cell::Cell::new(0));
236 #[cfg(not(target_arch = "wasm32"))]
237 let hook = precommit_test_hook::install({
238 let fired = fired.clone();
239 move |_| {
240 fired.set(fired.get() + 1);
241 assert!(before_commit.drain().is_empty());
242 }
243 });
244 state_write_with_events(context.mls_storage(), bus, |tx, events| {
245 let storage = tx.storage();
246 let db = storage.db();
247 let decide = ConsentWrite::Decide(Decision::Set(ConsentState::Allowed));
248 let changes: Vec<_> = write_consent(&db, &committed, decide, Owed::Mark)?
249 .into_iter()
250 .collect();
251 emit_consent_changes(&context, events, &db, &changes)?;
252 Ok::<_, StorageError>(TransactionOutcome::Continue(()))
253 })?;
254 #[cfg(not(target_arch = "wasm32"))]
255 drop(hook);
256 #[cfg(not(target_arch = "wasm32"))]
257 assert!(fired.get() > 0);
258 assert_eq!(
259 context
260 .db()
261 .consent_row(&committed)?
262 .map(|record| record.state),
263 Some(ConsentState::Allowed)
264 );
265 assert!(
266 matches!(subscription.drain().pop().and_then(|item| item.client), Some(ClientEvent::ConsentChanged(change)) if change.entity == "committed")
267 );
268
269 let rolled_back = ConsentKey::inbox("rolled_back");
270 state_write_with_events::<_, (), StorageError>(
271 context.mls_storage(),
272 bus,
273 |tx, events| {
274 let storage = tx.storage();
275 let db = storage.db();
276 let decide = ConsentWrite::Decide(Decision::Set(ConsentState::Denied));
277 let changes: Vec<_> = write_consent(&db, &rolled_back, decide, Owed::Mark)?
278 .into_iter()
279 .collect();
280 emit_consent_changes(&context, events, &db, &changes)?;
281 Ok(TransactionOutcome::Rollback)
282 },
283 )?;
284 assert!(context.db().consent_row(&rolled_back)?.is_none());
285 assert!(subscription.drain().is_empty());
286 }
287}