app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

activity.rs (12340B)


      1 use radroots_app_view::{
      2     ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, SettingsPreference,
      3     SettingsSection,
      4 };
      5 use rusqlite::{Connection, params};
      6 
      7 use crate::AppSqliteError;
      8 
      9 pub const APP_ACTIVITY_CONTEXT_LIMIT: usize = 64;
     10 pub const APP_ACTIVITY_RETENTION_LIMIT: i64 = 5_000;
     11 
     12 pub struct AppActivityRepository<'a> {
     13     connection: &'a Connection,
     14 }
     15 
     16 impl<'a> AppActivityRepository<'a> {
     17     pub fn new(connection: &'a Connection) -> Self {
     18         Self { connection }
     19     }
     20 
     21     pub fn record(&self, kind: &AppActivityKind) -> Result<(), AppSqliteError> {
     22         let activity_event_id = ActivityEventId::new().to_string();
     23         let event_kind = kind.storage_key();
     24         let settings_section = settings_section_value(kind);
     25         let settings_preference = settings_preference_value(kind);
     26         let preference_enabled = preference_enabled_value(kind);
     27 
     28         self.connection
     29             .execute(
     30                 "INSERT INTO activity_events (
     31                     activity_event_id,
     32                     event_kind,
     33                     settings_section,
     34                     settings_preference,
     35                     preference_enabled
     36                 ) VALUES (?1, ?2, ?3, ?4, ?5)",
     37                 params![
     38                     activity_event_id,
     39                     event_kind,
     40                     settings_section,
     41                     settings_preference,
     42                     preference_enabled,
     43                 ],
     44             )
     45             .map_err(|source| AppSqliteError::Query {
     46                 operation: "record activity event",
     47                 source,
     48             })?;
     49 
     50         self.trim_retained_events(APP_ACTIVITY_RETENTION_LIMIT)?;
     51 
     52         Ok(())
     53     }
     54 
     55     pub fn load_recent(&self, limit: usize) -> Result<Vec<AppActivityEvent>, AppSqliteError> {
     56         let mut statement = self
     57             .connection
     58             .prepare(
     59                 "SELECT
     60                     activity_event_id,
     61                     recorded_at,
     62                     event_kind,
     63                     settings_section,
     64                     settings_preference,
     65                     preference_enabled
     66                  FROM activity_events
     67                  ORDER BY recorded_at DESC, activity_event_id DESC
     68                  LIMIT ?1",
     69             )
     70             .map_err(|source| AppSqliteError::Query {
     71                 operation: "prepare recent activity query",
     72                 source,
     73             })?;
     74         let rows = statement
     75             .query_map([limit as i64], |row| {
     76                 let activity_event_id = row.get::<_, String>(0)?;
     77                 let recorded_at = row.get::<_, String>(1)?;
     78                 let event_kind = row.get::<_, String>(2)?;
     79                 let settings_section = row.get::<_, Option<String>>(3)?;
     80                 let settings_preference = row.get::<_, Option<String>>(4)?;
     81                 let preference_enabled = row.get::<_, Option<i64>>(5)?;
     82 
     83                 Ok((
     84                     activity_event_id,
     85                     recorded_at,
     86                     event_kind,
     87                     settings_section,
     88                     settings_preference,
     89                     preference_enabled,
     90                 ))
     91             })
     92             .map_err(|source| AppSqliteError::Query {
     93                 operation: "query recent activity events",
     94                 source,
     95             })?;
     96 
     97         rows.map(|row| {
     98             let (
     99                 activity_event_id,
    100                 recorded_at,
    101                 event_kind,
    102                 settings_section,
    103                 settings_preference,
    104                 preference_enabled,
    105             ) = row.map_err(|source| AppSqliteError::Query {
    106                 operation: "read recent activity event row",
    107                 source,
    108             })?;
    109 
    110             decode_activity_event(
    111                 &activity_event_id,
    112                 recorded_at,
    113                 event_kind,
    114                 settings_section,
    115                 settings_preference,
    116                 preference_enabled,
    117             )
    118         })
    119         .collect()
    120     }
    121 
    122     pub fn load_context(&self, limit: usize) -> Result<AppActivityContext, AppSqliteError> {
    123         Ok(AppActivityContext::from_recent_events(
    124             self.load_recent(limit)?,
    125         ))
    126     }
    127 
    128     fn trim_retained_events(&self, retention_limit: i64) -> Result<(), AppSqliteError> {
    129         self.connection
    130             .execute(
    131                 "DELETE FROM activity_events
    132                  WHERE activity_event_id IN (
    133                      SELECT activity_event_id
    134                      FROM activity_events
    135                      ORDER BY recorded_at DESC, activity_event_id DESC
    136                      LIMIT -1 OFFSET ?1
    137                  )",
    138                 [retention_limit],
    139             )
    140             .map_err(|source| AppSqliteError::Query {
    141                 operation: "trim retained activity events",
    142                 source,
    143             })?;
    144 
    145         Ok(())
    146     }
    147 }
    148 
    149 fn decode_activity_event(
    150     activity_event_id: &str,
    151     recorded_at: String,
    152     event_kind: String,
    153     settings_section: Option<String>,
    154     settings_preference: Option<String>,
    155     preference_enabled: Option<i64>,
    156 ) -> Result<AppActivityEvent, AppSqliteError> {
    157     let kind = match event_kind.as_str() {
    158         "home_opened" => AppActivityKind::HomeOpened,
    159         "settings_opened" => AppActivityKind::SettingsOpened {
    160             section: decode_settings_section("settings_section", settings_section)?,
    161         },
    162         "settings_section_selected" => AppActivityKind::SettingsSectionSelected {
    163             section: decode_settings_section("settings_section", settings_section)?,
    164         },
    165         "settings_preference_updated" => AppActivityKind::SettingsPreferenceUpdated {
    166             preference: decode_settings_preference("settings_preference", settings_preference)?,
    167             enabled: decode_preference_enabled(preference_enabled)?,
    168         },
    169         other => {
    170             return Err(AppSqliteError::DecodeEnum {
    171                 field: "event_kind",
    172                 value: other.to_owned(),
    173             });
    174         }
    175     };
    176 
    177     Ok(AppActivityEvent {
    178         activity_event_id: activity_event_id
    179             .parse()
    180             .map_err(|_| AppSqliteError::DecodeId {
    181                 field: "activity_event_id",
    182                 value: activity_event_id.to_owned(),
    183             })?,
    184         recorded_at,
    185         kind,
    186     })
    187 }
    188 
    189 fn decode_settings_section(
    190     field: &'static str,
    191     value: Option<String>,
    192 ) -> Result<SettingsSection, AppSqliteError> {
    193     match value.as_deref() {
    194         Some("account") => Ok(SettingsSection::Account),
    195         Some("farm") => Ok(SettingsSection::Farm),
    196         Some("settings") => Ok(SettingsSection::Settings),
    197         Some("about") => Ok(SettingsSection::About),
    198         Some(other) => Err(AppSqliteError::DecodeEnum {
    199             field,
    200             value: other.to_owned(),
    201         }),
    202         None => Err(AppSqliteError::MissingColumn { field }),
    203     }
    204 }
    205 
    206 fn decode_settings_preference(
    207     field: &'static str,
    208     value: Option<String>,
    209 ) -> Result<SettingsPreference, AppSqliteError> {
    210     match value.as_deref() {
    211         Some("allow_relay_connections") => Ok(SettingsPreference::AllowRelayConnections),
    212         Some("use_media_servers") => Ok(SettingsPreference::UseMediaServers),
    213         Some("use_nip05") => Ok(SettingsPreference::UseNip05),
    214         Some("launch_at_login") => Ok(SettingsPreference::LaunchAtLogin),
    215         Some(other) => Err(AppSqliteError::DecodeEnum {
    216             field,
    217             value: other.to_owned(),
    218         }),
    219         None => Err(AppSqliteError::MissingColumn { field }),
    220     }
    221 }
    222 
    223 fn decode_preference_enabled(value: Option<i64>) -> Result<bool, AppSqliteError> {
    224     match value {
    225         Some(0) => Ok(false),
    226         Some(1) => Ok(true),
    227         Some(other) => Err(AppSqliteError::DecodeEnum {
    228             field: "preference_enabled",
    229             value: other.to_string(),
    230         }),
    231         None => Err(AppSqliteError::MissingColumn {
    232             field: "preference_enabled",
    233         }),
    234     }
    235 }
    236 
    237 fn settings_section_value(kind: &AppActivityKind) -> Option<&'static str> {
    238     match kind {
    239         AppActivityKind::SettingsOpened { section }
    240         | AppActivityKind::SettingsSectionSelected { section } => Some(match section {
    241             SettingsSection::Account => "account",
    242             SettingsSection::Farm => "farm",
    243             SettingsSection::Settings => "settings",
    244             SettingsSection::About => "about",
    245         }),
    246         _ => None,
    247     }
    248 }
    249 
    250 fn settings_preference_value(kind: &AppActivityKind) -> Option<&'static str> {
    251     match kind {
    252         AppActivityKind::SettingsPreferenceUpdated { preference, .. } => {
    253             Some(preference.storage_key())
    254         }
    255         _ => None,
    256     }
    257 }
    258 
    259 fn preference_enabled_value(kind: &AppActivityKind) -> Option<i64> {
    260     match kind {
    261         AppActivityKind::SettingsPreferenceUpdated { enabled, .. } => Some(i64::from(*enabled)),
    262         _ => None,
    263     }
    264 }
    265 
    266 #[cfg(test)]
    267 mod tests {
    268     use radroots_app_view::{AppActivityKind, SettingsPreference, SettingsSection};
    269     use rusqlite::Connection;
    270 
    271     use crate::{AppSqliteStore, DatabaseTarget};
    272 
    273     use super::{APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository};
    274 
    275     #[test]
    276     fn activity_repository_records_and_loads_typed_recent_events() {
    277         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    278         let repository = store.activity_repository();
    279 
    280         repository
    281             .record(&AppActivityKind::HomeOpened)
    282             .expect("record home opened");
    283         repository
    284             .record(&AppActivityKind::SettingsOpened {
    285                 section: SettingsSection::Farm,
    286             })
    287             .expect("record settings opened");
    288         repository
    289             .record(&AppActivityKind::SettingsPreferenceUpdated {
    290                 preference: SettingsPreference::LaunchAtLogin,
    291                 enabled: true,
    292             })
    293             .expect("record settings preference");
    294 
    295         let recent = repository.load_recent(8).expect("load recent events");
    296 
    297         assert_eq!(recent.len(), 3);
    298         assert_eq!(
    299             recent[0].kind,
    300             AppActivityKind::SettingsPreferenceUpdated {
    301                 preference: SettingsPreference::LaunchAtLogin,
    302                 enabled: true,
    303             }
    304         );
    305         assert_eq!(
    306             recent[1].kind,
    307             AppActivityKind::SettingsOpened {
    308                 section: SettingsSection::Farm,
    309             }
    310         );
    311         assert_eq!(recent[2].kind, AppActivityKind::HomeOpened);
    312     }
    313 
    314     #[test]
    315     fn activity_repository_load_context_uses_default_context_limit() {
    316         let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
    317         let repository = store.activity_repository();
    318 
    319         repository
    320             .record(&AppActivityKind::HomeOpened)
    321             .expect("record home opened");
    322 
    323         let context = repository
    324             .load_context(APP_ACTIVITY_CONTEXT_LIMIT)
    325             .expect("load activity context");
    326 
    327         assert_eq!(context.recent_events.len(), 1);
    328         assert_eq!(context.recent_events[0].kind, AppActivityKind::HomeOpened);
    329     }
    330 
    331     #[test]
    332     fn activity_repository_trims_events_to_retention_limit() {
    333         let connection = Connection::open_in_memory().expect("open in-memory connection");
    334         connection
    335             .execute_batch(include_str!("../../migrations/0001_init.sql"))
    336             .expect("apply init migration");
    337         connection
    338             .execute_batch(include_str!("../../migrations/0002_activity_journal.sql"))
    339             .expect("apply activity migration");
    340         let repository = AppActivityRepository::new(&connection);
    341 
    342         for _ in 0..(APP_ACTIVITY_RETENTION_LIMIT + 8) {
    343             repository
    344                 .record(&AppActivityKind::HomeOpened)
    345                 .expect("record activity event");
    346         }
    347 
    348         let retained = count_rows(&connection, "activity_events");
    349 
    350         assert_eq!(retained, APP_ACTIVITY_RETENTION_LIMIT);
    351     }
    352 
    353     fn count_rows(connection: &Connection, table_name: &str) -> i64 {
    354         let sql = format!("SELECT COUNT(*) FROM {table_name}");
    355         connection
    356             .query_row(&sql, [], |row| row.get(0))
    357             .expect("row count query should succeed")
    358     }
    359 }