Skip to main content

xmtp_db/encrypted_store/
group_intent.rs

1use std::collections::HashMap;
2
3use derive_builder::Builder;
4use diesel::{
5    backend::Backend,
6    connection::DefaultLoadingMode,
7    deserialize::{self, FromSql, FromSqlRow},
8    expression::AsExpression,
9    prelude::*,
10    serialize::{self, IsNull, Output, ToSql},
11    sql_types::Integer,
12};
13use itertools::Itertools;
14use serde::{Deserialize, Serialize};
15use xmtp_common::fmt;
16use xmtp_proto::types::{Cursor, GroupId};
17
18use super::{
19    ConnectionExt, Sqlite,
20    db_connection::DbConnection,
21    schema::group_intents::{self, dsl},
22};
23use crate::{
24    Delete, NotFound, StorageError, group_message::QueryGroupMessage, impl_fetch, impl_store,
25};
26
27mod error;
28mod prepared;
29mod types;
30pub use error::*;
31pub use prepared::*;
32pub use types::*;
33
34pub type ID = i32;
35
36#[repr(i32)]
37#[derive(
38    Debug,
39    Clone,
40    Copy,
41    PartialEq,
42    Eq,
43    AsExpression,
44    FromSqlRow,
45    Serialize,
46    Deserialize,
47    strum::EnumIter,
48)]
49#[diesel(sql_type = Integer)]
50pub enum IntentKind {
51    SendMessage = 1,
52    KeyUpdate = 2,
53    MetadataUpdate = 3,
54    UpdateGroupMembership = 4,
55    UpdateAdminList = 5,
56    UpdatePermission = 6,
57    ReaddInstallations = 7,
58    ProposeMemberUpdate = 8,
59    ProposeGroupContextExtensions = 9,
60    CommitPendingProposals = 10,
61    /// One-time bootstrap commit that flips a group from the legacy
62    /// GroupContextExtensions-backed metadata layout onto the AppData
63    /// dictionary. Distinct from [`Self::ProposeGroupContextExtensions`]
64    /// because the payload shape is different (it bundles a GCE proposal
65    /// with a fan-out of `AppDataUpdate` proposals) and because the
66    /// dispatch path in `mls_sync` needs an explicit marker rather than
67    /// sniffing the extension-set shape.
68    #[doc(alias = "AppData migration")]
69    BootstrapMigration = 11,
70    /// Generic AppData component write. The intent payload carries a
71    /// `(component_id, AppDataUpdateOp)` pair where `AppDataUpdateOp` is
72    /// either `Replace(bytes)` (full-replace components — Bytes / String
73    /// types) or `DeltaWithBase { pre, post }` (TlsMap / TlsSet types,
74    /// where the handler computes the residual delta at commit time from
75    /// the current state, the pre value, and the post value).
76    ///
77    /// Replaces the proliferation of per-component IntentKinds. Existing
78    /// typed intents (`UpdateAdminList`, `UpdatePermission`,
79    /// `MetadataUpdate`) are not migrated by the introducing PR — they
80    /// continue to work, and a follow-on can fold them in.
81    AppDataUpdate = 12,
82}
83
84impl IntentKind {
85    /// Every kind this build knows how to deserialize, as a lazy
86    /// iterator — collect into a `Vec` only where a filter needs one.
87    /// Production queries pass these as the `allowed_kinds` filter so
88    /// rows written by a NEWER build (which may use discriminants this
89    /// build has no variant for) are excluded in SQL instead of
90    /// poisoning the whole `load()` — `FromSql` errors on unknown
91    /// discriminants, and one such row would otherwise wedge every
92    /// intent query for the group after an app downgrade. Unknown-kind
93    /// rows stay untouched in the table and resume processing when the
94    /// app is upgraded again.
95    ///
96    /// Exhaustive by construction: `strum::EnumIter` generates the
97    /// iteration over every variant, so a newly added `IntentKind` is
98    /// included automatically — which is exactly right here, since every
99    /// variant is, by definition, a kind this build can deserialize.
100    pub fn all() -> impl Iterator<Item = IntentKind> {
101        use strum::IntoEnumIterator;
102        IntentKind::iter()
103    }
104}
105
106impl std::fmt::Display for IntentKind {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        let description = match self {
109            IntentKind::SendMessage => "SendMessage",
110            IntentKind::KeyUpdate => "KeyUpdate",
111            IntentKind::MetadataUpdate => "MetadataUpdate",
112            IntentKind::UpdateGroupMembership => "UpdateGroupMembership",
113            IntentKind::UpdateAdminList => "UpdateAdminList",
114            IntentKind::UpdatePermission => "UpdatePermission",
115            IntentKind::ReaddInstallations => "ReaddInstallations",
116            IntentKind::ProposeMemberUpdate => "ProposeMemberUpdate",
117            IntentKind::ProposeGroupContextExtensions => "ProposeGroupContextExtensions",
118            IntentKind::CommitPendingProposals => "CommitPendingProposals",
119            IntentKind::BootstrapMigration => "BootstrapMigration",
120            IntentKind::AppDataUpdate => "AppDataUpdate",
121        };
122        write!(f, "{}", description)
123    }
124}
125
126#[repr(i32)]
127#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
128#[diesel(sql_type = Integer)]
129pub enum IntentState {
130    ToPublish = 1,
131    Published = 2,
132    Committed = 3,
133    Error = 4,
134    Processed = 5,
135    /// Abandoned before publishing because its compare-and-swap guard no
136    /// longer matched the committed state — another member changed the field
137    /// first. Terminal and distinct from [`IntentState::Error`]: nothing went
138    /// wrong, the write is simply stale, and the caller is expected to
139    /// re-derive it from the value that actually landed and queue again.
140    Superseded = 6,
141}
142
143#[derive(Queryable, Selectable, Identifiable, PartialEq, Clone)]
144#[diesel(table_name = group_intents)]
145#[diesel(primary_key(id))]
146pub struct StoredGroupIntent {
147    pub id: ID,
148    pub kind: IntentKind,
149    pub group_id: GroupId,
150    pub data: Vec<u8>,
151    pub state: IntentState,
152    pub payload_hash: Option<Vec<u8>>,
153    pub post_commit_data: Option<Vec<u8>>,
154    pub publish_attempts: i32,
155    pub staged_commit: Option<Vec<u8>>,
156    pub published_in_epoch: Option<i64>,
157    pub should_push: bool,
158    pub sequence_id: Option<i64>,
159}
160
161impl std::fmt::Debug for StoredGroupIntent {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        write!(f, "StoredGroupIntent {{ ")?;
164        write!(f, "id: {}, ", self.id)?;
165        write!(f, "kind: {}, ", self.kind)?;
166        write!(
167            f,
168            "group_id: {}, ",
169            fmt::truncate_hex(hex::encode(self.group_id))
170        )?;
171        write!(f, "data: {}, ", fmt::truncate_hex(hex::encode(&self.data)))?;
172        write!(f, "state: {:?}, ", self.state)?;
173        write!(
174            f,
175            "payload_hash: {:?}, ",
176            self.payload_hash
177                .as_ref()
178                .map(|h| fmt::truncate_hex(hex::encode(h)))
179        )?;
180        write!(
181            f,
182            "post_commit_data: {:?}, ",
183            self.post_commit_data
184                .as_ref()
185                .map(|d| fmt::truncate_hex(hex::encode(d)))
186        )?;
187        write!(f, "publish_attempts: {:?}, ", self.publish_attempts)?;
188        write!(
189            f,
190            "staged_commit: {:?}, ",
191            self.staged_commit
192                .as_ref()
193                .map(|c| fmt::truncate_hex(hex::encode(c)))
194        )?;
195        write!(f, "published_in_epoch: {:?} ", self.published_in_epoch)?;
196        write!(f, " }}")?;
197        Ok(())
198    }
199}
200
201impl_fetch!(StoredGroupIntent, group_intents, ID, select);
202
203impl<C: ConnectionExt> Delete<StoredGroupIntent> for DbConnection<C> {
204    type Key = ID;
205    fn delete(&self, key: ID) -> Result<usize, StorageError> {
206        Ok(self
207            .raw_query(|raw_conn| diesel::delete(dsl::group_intents.find(key)).execute(raw_conn))?)
208    }
209}
210
211/// NewGroupIntent is the data needed to create a new group intent.
212/// Do not use this struct directly outside of the storage module.
213/// Use the `queue_intent` method on `MlsGroup` instead.
214#[derive(Insertable, Debug, PartialEq, Clone, Builder)]
215#[diesel(table_name = group_intents)]
216#[builder(setter(into), build_fn(error = "StorageError"))]
217pub struct NewGroupIntent {
218    pub kind: IntentKind,
219    pub group_id: GroupId,
220    pub data: Vec<u8>,
221    pub should_push: bool,
222    #[builder(default = "IntentState::ToPublish")]
223    pub state: IntentState,
224}
225
226impl_store!(NewGroupIntent, group_intents);
227
228impl NewGroupIntent {
229    pub fn builder() -> NewGroupIntentBuilder {
230        NewGroupIntentBuilder::default()
231    }
232
233    pub fn new(
234        kind: IntentKind,
235        group_id: impl Into<GroupId>,
236        data: Vec<u8>,
237        should_push: bool,
238    ) -> Self {
239        Self {
240            kind,
241            group_id: group_id.into(),
242            data,
243            state: IntentState::ToPublish,
244            should_push,
245        }
246    }
247}
248
249pub trait QueryGroupIntent {
250    fn insert_group_intent(
251        &self,
252        to_save: NewGroupIntent,
253    ) -> Result<StoredGroupIntent, crate::ConnectionError>;
254
255    // Query for group_intents by group_id, optionally filtering by state and kind
256    fn find_group_intents<Id: AsRef<[u8]>>(
257        &self,
258        group_id: Id,
259        allowed_states: Option<Vec<IntentState>>,
260        allowed_kinds: Option<Vec<IntentKind>>,
261    ) -> Result<Vec<StoredGroupIntent>, crate::ConnectionError>;
262
263    // Set the intent with the given ID to `Published` and set the payload hash. Optionally add
264    // `post_commit_data`
265    fn set_group_intent_published(
266        &self,
267        intent_id: ID,
268        payload_hash: &[u8],
269        post_commit_data: Option<Vec<u8>>,
270        staged_commit: Option<Vec<u8>>,
271        published_in_epoch: i64,
272    ) -> Result<(), StorageError>;
273
274    // Set the intent with the given ID to `Committed`
275    fn set_group_intent_committed(&self, intent_id: ID, cursor: Cursor)
276    -> Result<(), StorageError>;
277
278    // Set the intent with the given ID to `Committed`
279    fn set_group_intent_processed(&self, intent_id: ID) -> Result<(), StorageError>;
280
281    /// Set the intent with the given ID to `Superseded` — abandoned because its
282    /// compare-and-swap guard no longer matches the committed state.
283    fn set_group_intent_superseded(&self, intent_id: ID) -> Result<(), StorageError>;
284
285    /// Abandon every unpublished and unconfirmed intent for a group that this
286    /// installation is no longer a member of. Returns the number abandoned.
287    ///
288    /// These become [`IntentState::Error`], not `Superseded`: the write did not
289    /// lose a compare-and-swap race, so reporting it as one would tell the
290    /// caller to re-derive from a value that did not change and queue again.
291    ///
292    /// `Committed` intents are deliberately excluded: their post-commit work
293    /// already landed on the network and may still owe Welcomes to members this
294    /// installation added, which must still be published.
295    fn supersede_pending_intents_for_inactive_group(
296        &self,
297        group_id: &[u8],
298    ) -> Result<usize, StorageError>;
299
300    // Set the intent with the given ID to `ToPublish`. Wipe any values for `payload_hash` and
301    // `post_commit_data`
302    fn set_group_intent_to_publish(&self, intent_id: ID) -> Result<(), StorageError>;
303
304    /// Set the intent with the given ID to `Error`
305    fn set_group_intent_error(&self, intent_id: ID) -> Result<(), StorageError>;
306
307    // Simple lookup of intents by payload hash, meant to be used when processing messages off the
308    // network
309    fn find_group_intent_by_payload_hash(
310        &self,
311        payload_hash: &[u8],
312    ) -> Result<Option<StoredGroupIntent>, StorageError>;
313
314    /// True when a row with this payload hash exists but carries an
315    /// `IntentKind` this build cannot decode.
316    ///
317    /// The hash is unique and is the SHA-256 of this installation's own
318    /// prepared envelope, so a match identifies our own echo. Identity must not
319    /// be filtered by kind: reporting "no intent" for our own message sends it
320    /// down the external-message path. Callers use this to reject the envelope
321    /// terminally instead of failing the whole query and retrying forever.
322    fn own_intent_kind_is_unreadable(&self, payload_hash: &[u8]) -> Result<bool, StorageError>;
323
324    /// find the commit message refresh state for each intent payload hash
325    fn find_dependant_commits<P: AsRef<[u8]>>(
326        &self,
327        payload_hashes: &[P],
328    ) -> Result<HashMap<PayloadHash, IntentDependency>, StorageError>;
329
330    fn increment_intent_publish_attempt_count(&self, intent_id: ID) -> Result<(), StorageError>;
331
332    fn set_group_intent_error_and_fail_msg(
333        &self,
334        intent: &StoredGroupIntent,
335        msg_id: Option<Vec<u8>>,
336    ) -> Result<(), StorageError>;
337}
338
339impl<T> QueryGroupIntent for &T
340where
341    T: QueryGroupIntent,
342{
343    fn insert_group_intent(
344        &self,
345        to_save: NewGroupIntent,
346    ) -> Result<StoredGroupIntent, crate::ConnectionError> {
347        (**self).insert_group_intent(to_save)
348    }
349
350    fn find_group_intents<Id: AsRef<[u8]>>(
351        &self,
352        group_id: Id,
353        allowed_states: Option<Vec<IntentState>>,
354        allowed_kinds: Option<Vec<IntentKind>>,
355    ) -> Result<Vec<StoredGroupIntent>, crate::ConnectionError> {
356        (**self).find_group_intents(group_id, allowed_states, allowed_kinds)
357    }
358
359    fn set_group_intent_published(
360        &self,
361        intent_id: ID,
362        payload_hash: &[u8],
363        post_commit_data: Option<Vec<u8>>,
364        staged_commit: Option<Vec<u8>>,
365        published_in_epoch: i64,
366    ) -> Result<(), StorageError> {
367        (**self).set_group_intent_published(
368            intent_id,
369            payload_hash,
370            post_commit_data,
371            staged_commit,
372            published_in_epoch,
373        )
374    }
375
376    fn set_group_intent_committed(
377        &self,
378        intent_id: ID,
379        cursor: Cursor,
380    ) -> Result<(), StorageError> {
381        (**self).set_group_intent_committed(intent_id, cursor)
382    }
383
384    fn set_group_intent_processed(&self, intent_id: ID) -> Result<(), StorageError> {
385        (**self).set_group_intent_processed(intent_id)
386    }
387
388    fn set_group_intent_superseded(&self, intent_id: ID) -> Result<(), StorageError> {
389        (**self).set_group_intent_superseded(intent_id)
390    }
391
392    fn supersede_pending_intents_for_inactive_group(
393        &self,
394        group_id: &[u8],
395    ) -> Result<usize, StorageError> {
396        (**self).supersede_pending_intents_for_inactive_group(group_id)
397    }
398
399    fn set_group_intent_to_publish(&self, intent_id: ID) -> Result<(), StorageError> {
400        (**self).set_group_intent_to_publish(intent_id)
401    }
402
403    fn set_group_intent_error(&self, intent_id: ID) -> Result<(), StorageError> {
404        (**self).set_group_intent_error(intent_id)
405    }
406
407    fn find_group_intent_by_payload_hash(
408        &self,
409        payload_hash: &[u8],
410    ) -> Result<Option<StoredGroupIntent>, StorageError> {
411        (**self).find_group_intent_by_payload_hash(payload_hash)
412    }
413
414    fn own_intent_kind_is_unreadable(&self, payload_hash: &[u8]) -> Result<bool, StorageError> {
415        (**self).own_intent_kind_is_unreadable(payload_hash)
416    }
417
418    fn find_dependant_commits<P: AsRef<[u8]>>(
419        &self,
420        payload_hashes: &[P],
421    ) -> Result<HashMap<PayloadHash, IntentDependency>, StorageError> {
422        (**self).find_dependant_commits(payload_hashes)
423    }
424
425    fn increment_intent_publish_attempt_count(&self, intent_id: ID) -> Result<(), StorageError> {
426        (**self).increment_intent_publish_attempt_count(intent_id)
427    }
428
429    fn set_group_intent_error_and_fail_msg(
430        &self,
431        intent: &StoredGroupIntent,
432        msg_id: Option<Vec<u8>>,
433    ) -> Result<(), StorageError> {
434        (**self).set_group_intent_error_and_fail_msg(intent, msg_id)
435    }
436}
437
438impl<C: ConnectionExt> QueryGroupIntent for DbConnection<C> {
439    #[xmtp_common::db_span]
440    fn insert_group_intent(
441        &self,
442        to_save: NewGroupIntent,
443    ) -> Result<StoredGroupIntent, crate::ConnectionError> {
444        self.raw_query(|conn| {
445            diesel::insert_into(dsl::group_intents)
446                .values(to_save)
447                .returning(StoredGroupIntent::as_returning())
448                .get_result(conn)
449        })
450    }
451
452    // Query for group_intents by group_id, optionally filtering by state and kind
453    #[xmtp_common::db_span]
454    fn find_group_intents<Id: AsRef<[u8]>>(
455        &self,
456        group_id: Id,
457        allowed_states: Option<Vec<IntentState>>,
458        allowed_kinds: Option<Vec<IntentKind>>,
459    ) -> Result<Vec<StoredGroupIntent>, crate::ConnectionError> {
460        let group_id = group_id.as_ref();
461        let mut query = dsl::group_intents
462            .into_boxed()
463            .filter(dsl::group_id.eq(group_id));
464
465        if let Some(allowed_states) = allowed_states {
466            query = query.filter(dsl::state.eq_any(allowed_states));
467        }
468
469        if let Some(allowed_kinds) = allowed_kinds {
470            query = query.filter(dsl::kind.eq_any(allowed_kinds));
471        }
472
473        query = query.order(dsl::id.asc());
474
475        self.raw_query(|conn| {
476            query
477                .select(StoredGroupIntent::as_select())
478                .load::<StoredGroupIntent>(conn)
479        })
480    }
481
482    // Set the intent with the given ID to `Published` and set the payload hash. Optionally add
483    // `post_commit_data`
484    #[tracing::instrument(level = "debug", skip(self, payload_hash), fields(intent_id = intent_id, payload_hash = hex::encode(payload_hash)))]
485    fn set_group_intent_published(
486        &self,
487        intent_id: ID,
488        payload_hash: &[u8],
489        post_commit_data: Option<Vec<u8>>,
490        staged_commit: Option<Vec<u8>>,
491        published_in_epoch: i64,
492    ) -> Result<(), StorageError> {
493        let rows_changed = self.raw_query(|conn| {
494            diesel::update(dsl::group_intents)
495                .filter(dsl::id.eq(intent_id))
496                // State machine requires that the only valid state transition to Published is from
497                // ToPublish
498                .filter(dsl::state.eq(IntentState::ToPublish))
499                .set((
500                    dsl::state.eq(IntentState::Published),
501                    dsl::payload_hash.eq(payload_hash),
502                    dsl::post_commit_data.eq(post_commit_data),
503                    dsl::staged_commit.eq(staged_commit),
504                    dsl::published_in_epoch.eq(published_in_epoch),
505                ))
506                .execute(conn)
507        })?;
508
509        if rows_changed == 0 {
510            let already_published = self.raw_query(|conn| {
511                dsl::group_intents
512                    .filter(dsl::id.eq(intent_id))
513                    .select(StoredGroupIntent::as_select())
514                    .first::<StoredGroupIntent>(conn)
515            });
516
517            if already_published.is_ok() {
518                return Ok(());
519            } else {
520                return Err(NotFound::IntentForToPublish(intent_id).into());
521            }
522        }
523        Ok(())
524    }
525
526    // Set the intent with the given ID to `Committed`
527    #[tracing::instrument(level = "debug", skip(self))]
528    fn set_group_intent_committed(
529        &self,
530        intent_id: ID,
531        cursor: Cursor,
532    ) -> Result<(), StorageError> {
533        let rows_changed: usize = self.raw_query(|conn| {
534            diesel::update(dsl::group_intents)
535                .filter(dsl::id.eq(intent_id))
536                // State machine requires that the only valid state transition to Committed is from
537                // Published
538                .filter(dsl::state.eq(IntentState::Published))
539                .set((
540                    dsl::state.eq(IntentState::Committed),
541                    dsl::sequence_id.eq(cursor.0 as i64),
542                ))
543                .execute(conn)
544        })?;
545
546        // If nothing matched the query, return an error. Either ID or state was wrong
547        if rows_changed == 0 {
548            return Err(NotFound::IntentForCommitted(intent_id).into());
549        }
550
551        Ok(())
552    }
553
554    /// Mark the intent abandoned because its compare-and-swap guard no longer
555    /// matches. Terminal, and deliberately not `Error`: the caller needs to
556    /// tell a stale write apart from a genuine failure.
557    #[tracing::instrument(level = "debug", skip(self))]
558    fn set_group_intent_superseded(&self, intent_id: ID) -> Result<(), StorageError> {
559        let rows_changed = self.raw_query(|conn| {
560            diesel::update(dsl::group_intents)
561                .filter(dsl::id.eq(intent_id))
562                // State machine requires that the only valid state transition to
563                // Superseded is from ToPublish. The guard is evaluated at publish
564                // time, so without this filter a racing caller could abandon an
565                // intent that had already been published or committed.
566                .filter(dsl::state.eq(IntentState::ToPublish))
567                .set(dsl::state.eq(IntentState::Superseded))
568                .execute(conn)
569        })?;
570
571        if rows_changed == 0 {
572            return Err(NotFound::IntentForToPublish(intent_id).into());
573        }
574
575        Ok(())
576    }
577
578    /// Removal is terminal for work that has not been accepted by the group.
579    /// The state is `Error`, not `Superseded`: nothing raced this write.
580    /// A `ToPublish` intent can never be published now, and a `Published` one
581    /// can never be confirmed: its own echo is unreachable behind the inactive
582    /// boundary, and a later re-add installs fresh state past it. Leaving those
583    /// intents in place strands them, and a stranded `Published` state change
584    /// preempts every later intent on the group.
585    ///
586    /// The prepared attempt is cleared with the state so no stale bytes can be
587    /// reused against a new membership generation.
588    #[tracing::instrument(level = "debug", skip(self))]
589    fn supersede_pending_intents_for_inactive_group(
590        &self,
591        group_id: &[u8],
592    ) -> Result<usize, StorageError> {
593        let rows_changed = self.raw_query(|conn| {
594            diesel::update(dsl::group_intents)
595                .filter(dsl::group_id.eq(group_id))
596                .filter(
597                    dsl::state
598                        .eq(IntentState::ToPublish)
599                        .or(dsl::state.eq(IntentState::Published)),
600                )
601                .set((
602                    dsl::state.eq(IntentState::Error),
603                    dsl::prepared_envelopes.eq(None::<Vec<u8>>),
604                    dsl::staged_commit.eq(None::<Vec<u8>>),
605                    dsl::payload_hash.eq(None::<Vec<u8>>),
606                    dsl::published_in_epoch.eq(None::<i64>),
607                ))
608                .execute(conn)
609        })?;
610        Ok(rows_changed)
611    }
612
613    // Set the intent with the given ID to `Committed`
614    #[tracing::instrument(level = "debug", skip(self))]
615    fn set_group_intent_processed(&self, intent_id: ID) -> Result<(), StorageError> {
616        let rows_changed = self.raw_query(|conn| {
617            diesel::update(dsl::group_intents)
618                .filter(dsl::id.eq(intent_id))
619                .set(dsl::state.eq(IntentState::Processed))
620                .execute(conn)
621        })?;
622
623        // If nothing matched the query, return an error. Either ID or state was wrong
624        if rows_changed == 0 {
625            return Err(NotFound::IntentById(intent_id).into());
626        }
627
628        Ok(())
629    }
630
631    // Set the intent with the given ID to `ToPublish`. Wipe any values for `payload_hash` and
632    // `post_commit_data`
633    #[tracing::instrument(level = "debug", skip(self))]
634    fn set_group_intent_to_publish(&self, intent_id: ID) -> Result<(), StorageError> {
635        let rows_changed = self.raw_query(|conn| {
636            diesel::update(dsl::group_intents)
637                .filter(dsl::id.eq(intent_id))
638                // State machine requires that the only valid state transition to ToPublish is from
639                // Published
640                .filter(dsl::state.eq(IntentState::Published))
641                .set((
642                    dsl::state.eq(IntentState::ToPublish),
643                    // When moving to ToPublish, clear the payload hash and post commit data
644                    dsl::payload_hash.eq(None::<Vec<u8>>),
645                    dsl::post_commit_data.eq(None::<Vec<u8>>),
646                    dsl::published_in_epoch.eq(None::<i64>),
647                    dsl::staged_commit.eq(None::<Vec<u8>>),
648                    dsl::prepared_envelopes.eq(None::<Vec<u8>>),
649                ))
650                .execute(conn)
651        })?;
652
653        if rows_changed == 0 {
654            return Err(NotFound::IntentForPublish(intent_id).into());
655        }
656        Ok(())
657    }
658
659    /// Set the intent with the given ID to `Error`
660    #[tracing::instrument(level = "debug", skip(self))]
661    fn set_group_intent_error(&self, intent_id: ID) -> Result<(), StorageError> {
662        let rows_changed = self.raw_query(|conn| {
663            diesel::update(dsl::group_intents)
664                .filter(dsl::id.eq(intent_id))
665                .set(dsl::state.eq(IntentState::Error))
666                .execute(conn)
667        })?;
668
669        if rows_changed == 0 {
670            return Err(NotFound::IntentById(intent_id).into());
671        }
672
673        Ok(())
674    }
675
676    // Simple lookup of intents by payload hash, meant to be used when processing messages off the
677    // network
678    #[xmtp_common::db_span]
679    fn find_group_intent_by_payload_hash(
680        &self,
681        payload_hash: &[u8],
682    ) -> Result<Option<StoredGroupIntent>, StorageError> {
683        let result = self.raw_query(|conn| {
684            dsl::group_intents
685                .filter(dsl::payload_hash.eq(payload_hash))
686                .select(StoredGroupIntent::as_select())
687                .first::<StoredGroupIntent>(conn)
688                .optional()
689        })?;
690
691        Ok(result)
692    }
693
694    #[xmtp_common::db_span]
695    fn own_intent_kind_is_unreadable(&self, payload_hash: &[u8]) -> Result<bool, StorageError> {
696        // Read the discriminant, not the enum: this must answer for a row whose
697        // kind a newer build wrote and this one cannot decode.
698        let kind = self.raw_query(|conn| {
699            dsl::group_intents
700                .filter(dsl::payload_hash.eq(payload_hash))
701                .select(dsl::kind)
702                .first::<i32>(conn)
703                .optional()
704        })?;
705        // Derive the known set from the same iterator the kind filters use,
706        // so a newly added variant is covered without editing this.
707        Ok(kind.is_some_and(|kind| !IntentKind::all().any(|known| known as i32 == kind)))
708    }
709
710    /// Find the commit message refresh state for each intent by payload hash.
711    /// Returns a map from payload hash to a vector of group dependencies.
712    #[xmtp_common::db_span]
713    fn find_dependant_commits<P: AsRef<[u8]>>(
714        &self,
715        payload_hashes: &[P],
716    ) -> Result<HashMap<PayloadHash, IntentDependency>, StorageError> {
717        use super::schema::refresh_state;
718        use crate::encrypted_store::refresh_state::EntityKind;
719
720        let hashes = payload_hashes
721            .iter()
722            .map(|h| PayloadHashRef::from(h.as_ref()));
723
724        // Query all dependencies in a single database call
725        let map: HashMap<PayloadHash, Vec<IntentDependency>> = self.raw_query(|conn| {
726            dsl::group_intents
727                .filter(dsl::payload_hash.eq_any(hashes))
728                .inner_join(
729                    refresh_state::table.on(refresh_state::entity_id
730                        .eq(dsl::group_id)
731                        .and(refresh_state::entity_kind.eq(EntityKind::ApplicationMessage))),
732                )
733                .select((
734                    dsl::payload_hash.assume_not_null(),
735                    refresh_state::sequence_id,
736                    dsl::group_id,
737                ))
738                .load_iter::<(Vec<u8>, i64, GroupId), DefaultLoadingMode>(conn)?
739                .map_ok(|(hash, sequence_id, group_id)| {
740                    (
741                        PayloadHash::from(hash),
742                        IntentDependency {
743                            cursor: Cursor(sequence_id as u64),
744                            group_id,
745                        },
746                    )
747                })
748                .process_results(|iter| iter.into_grouping_map().collect())
749        })?;
750
751        let map = map
752            .into_iter()
753            .map(|(hash, mut d)| {
754                if d.len() > 1 {
755                    return Err(GroupIntentError::MoreThanOneDependency {
756                        payload_hash: hash.clone(),
757                        cursors: d.iter().map(|d| d.cursor).collect(),
758                        group_id: d[0].group_id,
759                    }
760                    .into());
761                }
762
763                // this should be impossible since the sql query wouldnt return anything for
764                // an empty payload hash.
765                let dep = d
766                    .pop()
767                    .ok_or_else(|| GroupIntentError::NoDependencyFound { hash: hash.clone() })
768                    .map_err(StorageError::from)?;
769                Ok::<_, StorageError>((hash, dep))
770            })
771            .try_collect()?;
772
773        Ok(map)
774    }
775
776    #[tracing::instrument(level = "debug", skip(self))]
777    fn increment_intent_publish_attempt_count(&self, intent_id: ID) -> Result<(), StorageError> {
778        self.raw_query(|conn| {
779            diesel::update(dsl::group_intents)
780                .filter(dsl::id.eq(intent_id))
781                .set(dsl::publish_attempts.eq(dsl::publish_attempts + 1))
782                .execute(conn)
783        })?;
784
785        Ok(())
786    }
787
788    #[tracing::instrument(level = "debug", skip_all, fields(intent_id = %intent.id, intent_kind = %intent.kind, group_id = %intent.group_id))]
789    fn set_group_intent_error_and_fail_msg(
790        &self,
791        intent: &StoredGroupIntent,
792        msg_id: Option<Vec<u8>>,
793    ) -> Result<(), StorageError> {
794        self.set_group_intent_error(intent.id)?;
795        if let Some(id) = msg_id {
796            self.set_delivery_status_to_failed(&id)?;
797        }
798        Ok(())
799    }
800}
801
802impl ToSql<Integer, Sqlite> for IntentKind
803where
804    i32: ToSql<Integer, Sqlite>,
805{
806    fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
807        out.set_value(*self as i32);
808        Ok(IsNull::No)
809    }
810}
811
812impl FromSql<Integer, Sqlite> for IntentKind
813where
814    i32: FromSql<Integer, Sqlite>,
815{
816    fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> deserialize::Result<Self> {
817        match i32::from_sql(bytes)? {
818            1 => Ok(IntentKind::SendMessage),
819            2 => Ok(IntentKind::KeyUpdate),
820            3 => Ok(IntentKind::MetadataUpdate),
821            4 => Ok(IntentKind::UpdateGroupMembership),
822            5 => Ok(IntentKind::UpdateAdminList),
823            6 => Ok(IntentKind::UpdatePermission),
824            7 => Ok(IntentKind::ReaddInstallations),
825            8 => Ok(IntentKind::ProposeMemberUpdate),
826            9 => Ok(IntentKind::ProposeGroupContextExtensions),
827            10 => Ok(IntentKind::CommitPendingProposals),
828            11 => Ok(IntentKind::BootstrapMigration),
829            12 => Ok(IntentKind::AppDataUpdate),
830            x => Err(format!("Unrecognized IntentKind variant {}", x).into()),
831        }
832    }
833}
834
835impl ToSql<Integer, Sqlite> for IntentState
836where
837    i32: ToSql<Integer, Sqlite>,
838{
839    fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
840        out.set_value(*self as i32);
841        Ok(IsNull::No)
842    }
843}
844
845impl FromSql<Integer, Sqlite> for IntentState
846where
847    i32: FromSql<Integer, Sqlite>,
848{
849    fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> deserialize::Result<Self> {
850        match i32::from_sql(bytes)? {
851            1 => Ok(IntentState::ToPublish),
852            2 => Ok(IntentState::Published),
853            3 => Ok(IntentState::Committed),
854            4 => Ok(IntentState::Error),
855            5 => Ok(IntentState::Processed),
856            6 => Ok(IntentState::Superseded),
857            x => Err(format!("Unrecognized variant {}", x).into()),
858        }
859    }
860}
861
862#[cfg(test)]
863pub(crate) mod tests {
864    use super::*;
865    use crate::{
866        Fetch, Store,
867        group::{GroupMembershipState, StoredGroup},
868        test_utils::with_connection,
869    };
870    use xmtp_common::{Generate, rand_vec};
871
872    fn insert_group<C: ConnectionExt>(conn: &DbConnection<C>, group_id: GroupId) {
873        StoredGroup::builder()
874            .id(group_id)
875            .created_at_ns(100)
876            .membership_state(GroupMembershipState::Allowed)
877            .added_by_inbox_id("placeholder_address")
878            .build()
879            .unwrap()
880            .store(conn)
881            .unwrap();
882    }
883
884    impl NewGroupIntent {
885        // Real group intents must always start as ToPublish. But for tests we allow forcing the
886        // state
887        pub fn new_test(
888            kind: IntentKind,
889            group_id: GroupId,
890            data: Vec<u8>,
891            state: IntentState,
892        ) -> Self {
893            Self {
894                kind,
895                group_id,
896                data,
897                state,
898                should_push: false,
899            }
900        }
901    }
902
903    fn find_first_intent<C: ConnectionExt>(
904        conn: &DbConnection<C>,
905        group_id: GroupId,
906    ) -> StoredGroupIntent {
907        conn.raw_query(|raw_conn| {
908            dsl::group_intents
909                .filter(dsl::group_id.eq(group_id))
910                .select(StoredGroupIntent::as_select())
911                .first(raw_conn)
912        })
913        .unwrap()
914    }
915
916    /// Exhaustiveness of `IntentKind::all()` is guaranteed by
917    /// `strum::EnumIter`, which iterates every variant; what it can't
918    /// check is the discriminant layout. Pin it here:
919    /// `unknown_kind_row_is_excluded_by_kind_filter` derives its future
920    /// discriminant as `all().len() + 1`, which is only "beyond every
921    /// known variant" while discriminants are exactly 1..=len with no
922    /// gaps or duplicates.
923    #[xmtp_common::test]
924    fn intent_kind_discriminants_are_contiguous() {
925        let mut discriminants: Vec<i32> = IntentKind::all().map(|k| k as i32).collect();
926        discriminants.sort_unstable();
927        let count = discriminants.len();
928        assert_eq!(
929            discriminants,
930            (1..=count as i32).collect::<Vec<_>>(),
931            "IntentKind discriminants must be exactly 1..={} with no gaps or duplicates",
932            count
933        );
934    }
935
936    /// Downgrade simulation: a row whose `kind` discriminant this build
937    /// doesn't know (written by a future version) must not poison
938    /// kind-filtered queries. Unfiltered queries still error — pinned
939    /// here so a future change to that behavior is a conscious one.
940    #[xmtp_common::test]
941    fn unknown_kind_row_is_excluded_by_kind_filter() {
942        let group_id = GroupId::generate();
943
944        with_connection(|conn| {
945            insert_group(conn, group_id);
946
947            // A known-kind intent this build must keep seeing.
948            NewGroupIntent::new_test(
949                IntentKind::SendMessage,
950                group_id,
951                rand_vec::<24>(),
952                IntentState::ToPublish,
953            )
954            .store(conn)
955            .unwrap();
956
957            // A future-kind row (discriminant beyond every known
958            // variant), inserted raw — exactly what a newer build
959            // leaves behind before an app downgrade.
960            let future_kind = IntentKind::all().count() as i32 + 1;
961            conn.raw_query(|raw_conn| {
962                diesel::insert_into(dsl::group_intents)
963                    .values((
964                        dsl::kind.eq(future_kind),
965                        dsl::group_id.eq(group_id),
966                        dsl::data.eq(rand_vec::<24>()),
967                        dsl::state.eq(IntentState::ToPublish),
968                        dsl::publish_attempts.eq(0),
969                        dsl::should_push.eq(false),
970                    ))
971                    .execute(raw_conn)
972            })
973            .unwrap();
974
975            // Kind-filtered (the production shape): unknown row is
976            // excluded in SQL, the known intent still comes back.
977            let intents = conn
978                .find_group_intents(
979                    group_id,
980                    Some(vec![IntentState::ToPublish]),
981                    Some(IntentKind::all().collect()),
982                )
983                .unwrap();
984            assert_eq!(intents.len(), 1);
985            assert_eq!(intents[0].kind, IntentKind::SendMessage);
986
987            // Unfiltered: the unknown discriminant fails row
988            // deserialization and poisons the whole query.
989            assert!(
990                conn.find_group_intents(group_id, Some(vec![IntentState::ToPublish]), None)
991                    .is_err(),
992                "unfiltered query should surface the FromSql error for unknown kinds"
993            );
994        })
995    }
996
997    /// Identity by payload hash must survive an unreadable kind. Filtering it
998    /// away would report "not our message" for our own echo, which sends a
999    /// commit we authored down the external-message path.
1000    #[xmtp_common::test]
1001    fn an_unreadable_own_intent_kind_is_reported_not_hidden() {
1002        let group_id = GroupId::generate();
1003
1004        with_connection(|conn| {
1005            insert_group(conn, group_id);
1006            let known_hash = rand_vec::<32>();
1007            let future_hash = rand_vec::<32>();
1008
1009            conn.raw_query(|raw_conn| {
1010                diesel::insert_into(dsl::group_intents)
1011                    .values((
1012                        dsl::kind.eq(IntentKind::SendMessage),
1013                        dsl::group_id.eq(group_id),
1014                        dsl::data.eq(rand_vec::<24>()),
1015                        dsl::state.eq(IntentState::Published),
1016                        dsl::payload_hash.eq(Some(known_hash.clone())),
1017                        dsl::publish_attempts.eq(0),
1018                        dsl::should_push.eq(false),
1019                    ))
1020                    .execute(raw_conn)
1021            })
1022            .unwrap();
1023
1024            let future_kind = IntentKind::all().count() as i32 + 1;
1025            conn.raw_query(|raw_conn| {
1026                diesel::insert_into(dsl::group_intents)
1027                    .values((
1028                        dsl::kind.eq(future_kind),
1029                        dsl::group_id.eq(group_id),
1030                        dsl::data.eq(rand_vec::<24>()),
1031                        dsl::state.eq(IntentState::Published),
1032                        dsl::payload_hash.eq(Some(future_hash.clone())),
1033                        dsl::publish_attempts.eq(0),
1034                        dsl::should_push.eq(false),
1035                    ))
1036                    .execute(raw_conn)
1037            })
1038            .unwrap();
1039
1040            // A readable kind is unaffected, and an absent hash is not ours.
1041            assert!(!conn.own_intent_kind_is_unreadable(&known_hash).unwrap());
1042            assert!(
1043                !conn
1044                    .own_intent_kind_is_unreadable(&rand_vec::<32>())
1045                    .unwrap()
1046            );
1047
1048            // The unreadable row is reported rather than erroring the query,
1049            // so the caller can reject the envelope terminally.
1050            assert!(conn.own_intent_kind_is_unreadable(&future_hash).unwrap());
1051            assert!(
1052                conn.find_group_intent_by_payload_hash(&future_hash)
1053                    .is_err(),
1054                "the typed lookup still cannot decode it; the probe is what callers use"
1055            );
1056        })
1057    }
1058
1059    #[xmtp_common::test]
1060    fn test_store_and_fetch() {
1061        let group_id = GroupId::generate();
1062        let data = rand_vec::<24>();
1063        let kind = IntentKind::UpdateGroupMembership;
1064        let state = IntentState::ToPublish;
1065
1066        let to_insert = NewGroupIntent::new_test(kind, group_id, data.clone(), state);
1067
1068        with_connection(|conn| {
1069            // Group needs to exist or FK constraint will fail
1070            insert_group(conn, group_id);
1071
1072            to_insert.store(conn).unwrap();
1073
1074            let results = conn
1075                .find_group_intents(group_id, Some(vec![IntentState::ToPublish]), None)
1076                .unwrap();
1077
1078            assert_eq!(results.len(), 1);
1079            assert_eq!(results[0].kind, kind);
1080            assert_eq!(results[0].data, data);
1081            assert_eq!(results[0].group_id.as_slice(), group_id.as_slice());
1082
1083            let id = results[0].id;
1084
1085            let fetched: StoredGroupIntent = conn.fetch(&id).unwrap().unwrap();
1086
1087            assert_eq!(fetched.id, id);
1088        })
1089    }
1090
1091    #[xmtp_common::test]
1092    fn test_query() {
1093        let group_id = GroupId::generate();
1094
1095        let test_intents: Vec<NewGroupIntent> = vec![
1096            NewGroupIntent::new_test(
1097                IntentKind::UpdateGroupMembership,
1098                group_id,
1099                rand_vec::<24>(),
1100                IntentState::ToPublish,
1101            ),
1102            NewGroupIntent::new_test(
1103                IntentKind::KeyUpdate,
1104                group_id,
1105                rand_vec::<24>(),
1106                IntentState::Published,
1107            ),
1108            NewGroupIntent::new_test(
1109                IntentKind::KeyUpdate,
1110                group_id,
1111                rand_vec::<24>(),
1112                IntentState::Committed,
1113            ),
1114        ];
1115
1116        with_connection(|conn| {
1117            // Group needs to exist or FK constraint will fail
1118            insert_group(conn, group_id);
1119
1120            for case in test_intents {
1121                case.store(conn).unwrap();
1122            }
1123
1124            // Can query for multiple states
1125            let mut results = conn
1126                .find_group_intents(
1127                    group_id,
1128                    Some(vec![IntentState::ToPublish, IntentState::Published]),
1129                    None,
1130                )
1131                .unwrap();
1132
1133            assert_eq!(results.len(), 2);
1134
1135            // Can query by kind
1136            results = conn
1137                .find_group_intents(group_id, None, Some(vec![IntentKind::KeyUpdate]))
1138                .unwrap();
1139            assert_eq!(results.len(), 2);
1140
1141            // Can query by kind and state
1142            results = conn
1143                .find_group_intents(
1144                    group_id,
1145                    Some(vec![IntentState::Committed]),
1146                    Some(vec![IntentKind::KeyUpdate]),
1147                )
1148                .unwrap();
1149
1150            assert_eq!(results.len(), 1);
1151
1152            // Can get no results
1153            results = conn
1154                .find_group_intents(
1155                    group_id,
1156                    Some(vec![IntentState::Committed]),
1157                    Some(vec![IntentKind::SendMessage]),
1158                )
1159                .unwrap();
1160
1161            assert_eq!(results.len(), 0);
1162
1163            // Can get all intents
1164            results = conn.find_group_intents(group_id, None, None).unwrap();
1165            assert_eq!(results.len(), 3);
1166        })
1167    }
1168
1169    #[xmtp_common::test]
1170    fn find_by_payload_hash() {
1171        let group_id = GroupId::generate();
1172
1173        with_connection(|conn| {
1174            insert_group(conn, group_id);
1175
1176            // Store the intent
1177            NewGroupIntent::new(
1178                IntentKind::UpdateGroupMembership,
1179                group_id,
1180                rand_vec::<24>(),
1181                false,
1182            )
1183            .store(conn)
1184            .unwrap();
1185
1186            // Find the intent with the ID populated
1187            let intent = find_first_intent(conn, group_id);
1188
1189            // Set the payload hash
1190            let payload_hash = rand_vec::<24>();
1191            let post_commit_data = rand_vec::<24>();
1192            conn.set_group_intent_published(
1193                intent.id,
1194                &payload_hash,
1195                Some(post_commit_data.clone()),
1196                None,
1197                1,
1198            )
1199            .unwrap();
1200
1201            let find_result = conn
1202                .find_group_intent_by_payload_hash(&payload_hash)
1203                .unwrap()
1204                .unwrap();
1205
1206            assert_eq!(find_result.id, intent.id);
1207            assert_eq!(find_result.published_in_epoch, Some(1));
1208        })
1209    }
1210
1211    #[xmtp_common::test]
1212    fn test_happy_path_state_transitions() {
1213        let group_id = GroupId::generate();
1214
1215        with_connection(|conn| {
1216            insert_group(conn, group_id);
1217
1218            // Store the intent
1219            NewGroupIntent::new(
1220                IntentKind::UpdateGroupMembership,
1221                group_id,
1222                rand_vec::<24>(),
1223                false,
1224            )
1225            .store(conn)
1226            .unwrap();
1227
1228            let mut intent = find_first_intent(conn, group_id);
1229
1230            // Set to published
1231            let payload_hash = rand_vec::<24>();
1232            let post_commit_data = rand_vec::<24>();
1233            conn.set_group_intent_published(
1234                intent.id,
1235                &payload_hash,
1236                Some(post_commit_data.clone()),
1237                None,
1238                1,
1239            )
1240            .unwrap();
1241
1242            intent = conn.fetch(&intent.id).unwrap().unwrap();
1243            assert_eq!(intent.state, IntentState::Published);
1244            assert_eq!(intent.payload_hash, Some(payload_hash.clone()));
1245            assert_eq!(intent.post_commit_data, Some(post_commit_data.clone()));
1246
1247            conn.set_group_intent_committed(intent.id, Cursor::default())
1248                .unwrap();
1249            // Refresh from the DB
1250            intent = conn.fetch(&intent.id).unwrap().unwrap();
1251            assert_eq!(intent.state, IntentState::Committed);
1252            // Make sure we haven't lost the payload hash
1253            assert_eq!(intent.payload_hash, Some(payload_hash.clone()));
1254        })
1255    }
1256
1257    #[xmtp_common::test]
1258    fn test_republish_state_transition() {
1259        let group_id = GroupId::generate();
1260
1261        with_connection(|conn| {
1262            insert_group(conn, group_id);
1263
1264            // Store the intent
1265            NewGroupIntent::new(
1266                IntentKind::UpdateGroupMembership,
1267                group_id,
1268                rand_vec::<24>(),
1269                false,
1270            )
1271            .store(conn)
1272            .unwrap();
1273
1274            let mut intent = find_first_intent(conn, group_id);
1275
1276            // Set to published
1277            let payload_hash = rand_vec::<24>();
1278            let post_commit_data = rand_vec::<24>();
1279            conn.set_group_intent_published(
1280                intent.id,
1281                &payload_hash,
1282                Some(post_commit_data.clone()),
1283                None,
1284                1,
1285            )
1286            .unwrap();
1287
1288            intent = conn.fetch(&intent.id).unwrap().unwrap();
1289            assert_eq!(intent.state, IntentState::Published);
1290            assert_eq!(intent.payload_hash, Some(payload_hash.clone()));
1291
1292            // Now revert back to ToPublish
1293            conn.set_group_intent_to_publish(intent.id).unwrap();
1294            intent = conn.fetch(&intent.id).unwrap().unwrap();
1295            assert_eq!(intent.state, IntentState::ToPublish);
1296            assert!(intent.payload_hash.is_none());
1297            assert!(intent.post_commit_data.is_none());
1298        })
1299    }
1300
1301    #[xmtp_common::test]
1302    fn test_invalid_state_transition() {
1303        let group_id = GroupId::generate();
1304
1305        with_connection(|conn| {
1306            insert_group(conn, group_id);
1307
1308            // Store the intent
1309            NewGroupIntent::new(
1310                IntentKind::UpdateGroupMembership,
1311                group_id,
1312                rand_vec::<24>(),
1313                false,
1314            )
1315            .store(conn)
1316            .unwrap();
1317
1318            let intent = find_first_intent(conn, group_id);
1319
1320            let commit_result = conn.set_group_intent_committed(intent.id, Cursor::default());
1321            assert!(commit_result.is_err());
1322            assert!(matches!(
1323                commit_result.err().unwrap(),
1324                StorageError::NotFound(_)
1325            ));
1326
1327            let to_publish_result = conn.set_group_intent_to_publish(intent.id);
1328            assert!(to_publish_result.is_err());
1329            assert!(matches!(
1330                to_publish_result.err().unwrap(),
1331                StorageError::NotFound(_)
1332            ));
1333        })
1334    }
1335
1336    #[xmtp_common::test]
1337    fn test_increment_publish_attempts() {
1338        let group_id = GroupId::generate();
1339        with_connection(|conn| {
1340            insert_group(conn, group_id);
1341            NewGroupIntent::new(
1342                IntentKind::UpdateGroupMembership,
1343                group_id,
1344                rand_vec::<24>(),
1345                false,
1346            )
1347            .store(conn)
1348            .unwrap();
1349
1350            let mut intent = find_first_intent(conn, group_id);
1351            assert_eq!(intent.publish_attempts, 0);
1352            conn.increment_intent_publish_attempt_count(intent.id)
1353                .unwrap();
1354            intent = find_first_intent(conn, group_id);
1355            assert_eq!(intent.publish_attempts, 1);
1356            conn.increment_intent_publish_attempt_count(intent.id)
1357                .unwrap();
1358            intent = find_first_intent(conn, group_id);
1359            assert_eq!(intent.publish_attempts, 2);
1360        })
1361    }
1362    #[xmtp_common::test]
1363    fn test_find_dependant_commits() {
1364        use crate::encrypted_store::refresh_state::{EntityKind, QueryRefreshState};
1365
1366        let group_id = GroupId::generate();
1367        let payload_hash1 = rand_vec::<24>();
1368        let payload_hash2 = rand_vec::<24>();
1369
1370        with_connection(|conn| {
1371            insert_group(conn, group_id);
1372            NewGroupIntent::new(IntentKind::SendMessage, group_id, rand_vec::<24>(), false)
1373                .store(conn)
1374                .unwrap();
1375
1376            let intent1 = find_first_intent(conn, group_id);
1377            conn.set_group_intent_published(intent1.id, &payload_hash1, None, None, 1)
1378                .unwrap();
1379
1380            NewGroupIntent::new(IntentKind::KeyUpdate, group_id, rand_vec::<24>(), false)
1381                .store(conn)
1382                .unwrap();
1383            let intents = conn.find_group_intents(group_id, None, None).unwrap();
1384            let intent2 = intents.iter().find(|i| i.id != intent1.id).unwrap();
1385            conn.set_group_intent_published(intent2.id, &payload_hash2, None, None, 1)
1386                .unwrap();
1387
1388            conn.update_cursor(group_id, EntityKind::ApplicationMessage, Cursor(100))
1389                .unwrap();
1390
1391            let result = conn
1392                .find_dependant_commits(&[&payload_hash1, &payload_hash2])
1393                .unwrap();
1394
1395            assert_eq!(result.len(), 2);
1396            let dep1 = result
1397                .get(&PayloadHash::from(payload_hash1.clone()))
1398                .unwrap();
1399            assert_eq!(dep1.cursor.0, 100);
1400
1401            assert_eq!(dep1.group_id.as_ref(), &group_id);
1402
1403            let dep2 = result
1404                .get(&PayloadHash::from(payload_hash2.clone()))
1405                .unwrap();
1406            assert_eq!(dep2.cursor.0, 100);
1407
1408            assert_eq!(dep2.group_id.as_ref(), &group_id);
1409        })
1410    }
1411
1412    #[xmtp_common::test]
1413    fn bootstrap_migration_intent_round_trips_through_sql() {
1414        // Exercises both the i32 → IntentKind::BootstrapMigration arm
1415        // and the Display impl. Cheap coverage for the new variant
1416        // that would otherwise sit dead until end-to-end migration tests.
1417        let group_id = GroupId::generate();
1418        let data = rand_vec::<24>();
1419        let kind = IntentKind::BootstrapMigration;
1420        let to_insert =
1421            NewGroupIntent::new_test(kind, group_id, data.clone(), IntentState::ToPublish);
1422
1423        with_connection(|conn| {
1424            insert_group(conn, group_id);
1425            to_insert.store(conn).unwrap();
1426
1427            let results = conn
1428                .find_group_intents(group_id, Some(vec![IntentState::ToPublish]), None)
1429                .unwrap();
1430
1431            assert_eq!(results.len(), 1);
1432            assert_eq!(results[0].kind, IntentKind::BootstrapMigration);
1433            assert_eq!(format!("{}", results[0].kind), "BootstrapMigration");
1434        })
1435    }
1436}