Skip to main content

pipedash_core/infrastructure/database/
device_repository.rs

1use std::collections::HashMap;
2
3use chrono::{
4    DateTime,
5    Utc,
6};
7
8use super::DatabasePool;
9use crate::domain::{
10    DomainError,
11    DomainResult,
12    NotificationPreferences,
13    PairedDevice,
14};
15
16type Row = (
17    String,
18    String,
19    String,
20    String,
21    DateTime<Utc>,
22    Option<DateTime<Utc>>,
23);
24
25const COLUMNS: &str = "id, token_hash, name, platform, created_at, last_seen_at";
26const UPDATE_ATTEMPTS: usize = 8;
27
28fn db_error(error: sqlx::Error) -> DomainError {
29    DomainError::DatabaseError(error.to_string())
30}
31
32fn device(row: Row) -> PairedDevice {
33    let (id, token_hash, name, platform, created_at, last_seen_at) = row;
34
35    PairedDevice {
36        id,
37        token_hash,
38        name,
39        platform,
40        created_at,
41        last_seen_at,
42    }
43}
44
45pub struct DeviceRepository {
46    pool: DatabasePool,
47}
48
49impl DeviceRepository {
50    pub fn new(pool: DatabasePool) -> Self {
51        Self { pool }
52    }
53
54    fn placeholder(&self, index: usize) -> String {
55        match &self.pool {
56            DatabasePool::Sqlite(_) => "?".to_string(),
57            DatabasePool::Postgres(_) => format!("${index}"),
58        }
59    }
60
61    pub async fn insert(&self, device: &PairedDevice) -> DomainResult<()> {
62        let sql = format!(
63            "INSERT INTO paired_devices ({COLUMNS}) VALUES ({}, {}, {}, {}, {}, {})",
64            self.placeholder(1),
65            self.placeholder(2),
66            self.placeholder(3),
67            self.placeholder(4),
68            self.placeholder(5),
69            self.placeholder(6),
70        );
71        self.execute_insert(sql, device).await
72    }
73
74    pub async fn list(&self) -> DomainResult<Vec<PairedDevice>> {
75        let sql = format!("SELECT {COLUMNS} FROM paired_devices ORDER BY created_at, id");
76        let rows = match &self.pool {
77            DatabasePool::Sqlite(p) => {
78                sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
79                    .fetch_all(p)
80                    .await
81            }
82            DatabasePool::Postgres(p) => {
83                sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
84                    .fetch_all(p)
85                    .await
86            }
87        }
88        .map_err(db_error)?;
89
90        Ok(rows.into_iter().map(device).collect())
91    }
92
93    pub async fn find_by_token(&self, token: &str) -> DomainResult<Option<PairedDevice>> {
94        let sql = format!(
95            "SELECT {COLUMNS} FROM paired_devices WHERE token_hash = {}",
96            self.placeholder(1)
97        );
98        let token_hash = PairedDevice::hash_token(token);
99        let row = match &self.pool {
100            DatabasePool::Sqlite(p) => {
101                sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
102                    .bind(token_hash)
103                    .fetch_optional(p)
104                    .await
105            }
106            DatabasePool::Postgres(p) => {
107                sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
108                    .bind(token_hash)
109                    .fetch_optional(p)
110                    .await
111            }
112        }
113        .map_err(db_error)?;
114
115        Ok(row.map(device))
116    }
117
118    pub async fn touch(&self, id: &str) -> DomainResult<()> {
119        let sql = format!(
120            "UPDATE paired_devices SET last_seen_at = {} WHERE id = {}",
121            self.placeholder(1),
122            self.placeholder(2)
123        );
124        let now = Utc::now();
125        match &self.pool {
126            DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
127                .bind(now)
128                .bind(id)
129                .execute(p)
130                .await
131                .map(|_| ()),
132            DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
133                .bind(now)
134                .bind(id)
135                .execute(p)
136                .await
137                .map(|_| ()),
138        }
139        .map_err(db_error)
140    }
141
142    pub async fn set_notification_preferences(
143        &self, id: &str, preferences: &NotificationPreferences,
144    ) -> DomainResult<bool> {
145        let sql = format!(
146            "UPDATE paired_devices SET notification_preferences = {} WHERE id = {}",
147            self.placeholder(1),
148            self.placeholder(2)
149        );
150        let json = serde_json::to_string(preferences)
151            .map_err(|error| DomainError::DatabaseError(error.to_string()))?;
152        let updated = match &self.pool {
153            DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
154                .bind(json)
155                .bind(id)
156                .execute(p)
157                .await
158                .map(|result| result.rows_affected()),
159            DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
160                .bind(json)
161                .bind(id)
162                .execute(p)
163                .await
164                .map(|result| result.rows_affected()),
165        }
166        .map_err(db_error)?;
167
168        Ok(updated > 0)
169    }
170
171    pub async fn update_notification_preferences(
172        &self, id: &str, mut change: impl FnMut(&mut NotificationPreferences) -> bool,
173    ) -> DomainResult<Option<NotificationPreferences>> {
174        for _ in 0..UPDATE_ATTEMPTS {
175            let Some(stored) = self.preferences_json(id).await? else {
176                return Ok(None);
177            };
178            let mut preferences: NotificationPreferences = stored
179                .as_deref()
180                .and_then(|json| serde_json::from_str(json).ok())
181                .unwrap_or_default();
182            if !change(&mut preferences) {
183                return Ok(Some(preferences));
184            }
185            let json = serde_json::to_string(&preferences)
186                .map_err(|error| DomainError::DatabaseError(error.to_string()))?;
187            if self.swap_preferences(id, stored.as_deref(), &json).await? {
188                return Ok(Some(preferences));
189            }
190        }
191
192        Err(DomainError::DatabaseError(
193            "Notification preferences kept changing while being saved".into(),
194        ))
195    }
196
197    async fn preferences_json(&self, id: &str) -> DomainResult<Option<Option<String>>> {
198        let sql = format!(
199            "SELECT notification_preferences FROM paired_devices WHERE id = {}",
200            self.placeholder(1)
201        );
202        let row = match &self.pool {
203            DatabasePool::Sqlite(p) => {
204                sqlx::query_as::<_, (Option<String>,)>(sqlx::AssertSqlSafe(sql))
205                    .bind(id)
206                    .fetch_optional(p)
207                    .await
208            }
209            DatabasePool::Postgres(p) => {
210                sqlx::query_as::<_, (Option<String>,)>(sqlx::AssertSqlSafe(sql))
211                    .bind(id)
212                    .fetch_optional(p)
213                    .await
214            }
215        }
216        .map_err(db_error)?;
217
218        Ok(row.map(|(json,)| json))
219    }
220
221    async fn swap_preferences(
222        &self, id: &str, expected: Option<&str>, json: &str,
223    ) -> DomainResult<bool> {
224        let same = match &self.pool {
225            DatabasePool::Sqlite(_) => "IS",
226            DatabasePool::Postgres(_) => "IS NOT DISTINCT FROM",
227        };
228        let sql = format!(
229            "UPDATE paired_devices SET notification_preferences = {} \
230             WHERE id = {} AND notification_preferences {same} {}",
231            self.placeholder(1),
232            self.placeholder(2),
233            self.placeholder(3)
234        );
235        let updated = match &self.pool {
236            DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
237                .bind(json)
238                .bind(id)
239                .bind(expected)
240                .execute(p)
241                .await
242                .map(|result| result.rows_affected()),
243            DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
244                .bind(json)
245                .bind(id)
246                .bind(expected)
247                .execute(p)
248                .await
249                .map(|result| result.rows_affected()),
250        }
251        .map_err(db_error)?;
252
253        Ok(updated > 0)
254    }
255
256    pub async fn notification_preferences(
257        &self,
258    ) -> DomainResult<HashMap<String, NotificationPreferences>> {
259        let sql = "SELECT id, notification_preferences FROM paired_devices \
260                   WHERE notification_preferences IS NOT NULL";
261        let rows = match &self.pool {
262            DatabasePool::Sqlite(p) => {
263                sqlx::query_as::<_, (String, String)>(sql)
264                    .fetch_all(p)
265                    .await
266            }
267            DatabasePool::Postgres(p) => {
268                sqlx::query_as::<_, (String, String)>(sql)
269                    .fetch_all(p)
270                    .await
271            }
272        }
273        .map_err(db_error)?;
274
275        Ok(rows
276            .into_iter()
277            .filter_map(|(id, json)| Some((id, serde_json::from_str(&json).ok()?)))
278            .collect())
279    }
280
281    pub async fn delete(&self, id: &str) -> DomainResult<bool> {
282        let sql = format!(
283            "DELETE FROM paired_devices WHERE id = {}",
284            self.placeholder(1)
285        );
286        let deleted = match &self.pool {
287            DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
288                .bind(id)
289                .execute(p)
290                .await
291                .map(|result| result.rows_affected()),
292            DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
293                .bind(id)
294                .execute(p)
295                .await
296                .map(|result| result.rows_affected()),
297        }
298        .map_err(db_error)?;
299
300        Ok(deleted > 0)
301    }
302
303    pub async fn delete_all(&self) -> DomainResult<u64> {
304        let sql = "DELETE FROM paired_devices";
305        match &self.pool {
306            DatabasePool::Sqlite(p) => sqlx::query(sql)
307                .execute(p)
308                .await
309                .map(|result| result.rows_affected()),
310            DatabasePool::Postgres(p) => sqlx::query(sql)
311                .execute(p)
312                .await
313                .map(|result| result.rows_affected()),
314        }
315        .map_err(db_error)
316    }
317
318    pub async fn import(&self, devices: &[PairedDevice]) -> DomainResult<()> {
319        let sql = format!(
320            "INSERT INTO paired_devices ({COLUMNS}) VALUES ({}, {}, {}, {}, {}, {})
321             ON CONFLICT (id) DO NOTHING",
322            self.placeholder(1),
323            self.placeholder(2),
324            self.placeholder(3),
325            self.placeholder(4),
326            self.placeholder(5),
327            self.placeholder(6),
328        );
329        for device in devices {
330            self.execute_insert(sql.clone(), device).await?;
331        }
332
333        Ok(())
334    }
335
336    async fn execute_insert(&self, sql: String, device: &PairedDevice) -> DomainResult<()> {
337        match &self.pool {
338            DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
339                .bind(&device.id)
340                .bind(&device.token_hash)
341                .bind(&device.name)
342                .bind(&device.platform)
343                .bind(device.created_at)
344                .bind(device.last_seen_at)
345                .execute(p)
346                .await
347                .map(|_| ()),
348            DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
349                .bind(&device.id)
350                .bind(&device.token_hash)
351                .bind(&device.name)
352                .bind(&device.platform)
353                .bind(device.created_at)
354                .bind(device.last_seen_at)
355                .execute(p)
356                .await
357                .map(|_| ()),
358        }
359        .map_err(db_error)
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::infrastructure::database::init_database;
367
368    #[tokio::test]
369    async fn sqlite_devices_are_found_by_token_touched_and_deleted() {
370        let dir = tempfile::tempdir().unwrap();
371        let pool = init_database(dir.path().join("devices.db")).await.unwrap();
372        let repository = DeviceRepository::new(DatabasePool::Sqlite(pool));
373        let (device, token) = PairedDevice::issue("Pixel", "android");
374
375        repository.insert(&device).await.unwrap();
376
377        let found = repository.find_by_token(&token).await.unwrap().unwrap();
378        assert_eq!(found.id, device.id);
379        assert!(found.last_seen_at.is_some());
380        assert_eq!(repository.find_by_token("wrong").await.unwrap(), None);
381        assert_eq!(
382            repository.find_by_token(&device.token_hash).await.unwrap(),
383            None
384        );
385
386        assert!(repository
387            .notification_preferences()
388            .await
389            .unwrap()
390            .is_empty());
391        let muted = NotificationPreferences {
392            long_run: false,
393            muted_pipelines: vec!["github__1__api".into()],
394            ..NotificationPreferences::default()
395        };
396        assert!(repository
397            .set_notification_preferences(&device.id, &muted)
398            .await
399            .unwrap());
400        assert!(!repository
401            .set_notification_preferences("unknown", &muted)
402            .await
403            .unwrap());
404        assert_eq!(
405            repository.notification_preferences().await.unwrap(),
406            HashMap::from([(device.id.clone(), muted)])
407        );
408
409        repository.touch(&device.id).await.unwrap();
410        let listed = repository.list().await.unwrap();
411        assert_eq!(listed.len(), 1);
412        assert!(listed[0].last_seen_at.is_some());
413
414        assert!(repository.delete(&device.id).await.unwrap());
415        assert!(!repository.delete(&device.id).await.unwrap());
416        assert_eq!(repository.find_by_token(&token).await.unwrap(), None);
417
418        repository.import(&listed).await.unwrap();
419        repository.import(&listed).await.unwrap();
420        assert_eq!(repository.list().await.unwrap(), listed);
421        assert_eq!(repository.delete_all().await.unwrap(), 1);
422        assert!(repository.list().await.unwrap().is_empty());
423    }
424
425    #[tokio::test(flavor = "multi_thread")]
426    async fn a_change_saved_between_read_and_write_is_kept_and_reapplied() {
427        let dir = tempfile::tempdir().unwrap();
428        let pool = init_database(dir.path().join("devices.db")).await.unwrap();
429        let repository = DeviceRepository::new(DatabasePool::Sqlite(pool));
430        let (device, _) = PairedDevice::issue("Pixel", "android");
431        repository.insert(&device).await.unwrap();
432        let request = crate::domain::OnceWatch {
433            pipeline_id: "github__1__api".into(),
434            since: Utc::now(),
435            fired_at: None,
436        };
437        let finish = crate::domain::RunFinish {
438            at: request.since + chrono::Duration::minutes(1),
439            previous: None,
440        };
441        let incoming = NotificationPreferences {
442            outage: false,
443            watch_once: vec![request],
444            ..NotificationPreferences::default()
445        };
446        repository
447            .set_notification_preferences(&device.id, &incoming)
448            .await
449            .unwrap();
450        let mut attempts = 0;
451
452        let saved = repository
453            .update_notification_preferences(&device.id, |stored| {
454                attempts += 1;
455                if attempts == 1 {
456                    let mut fired = stored.clone();
457                    assert!(!fired.fire_once("github__1__api", &finish).is_empty());
458                    tokio::task::block_in_place(|| {
459                        tokio::runtime::Handle::current()
460                            .block_on(repository.set_notification_preferences(&device.id, &fired))
461                    })
462                    .unwrap();
463                }
464                let mut next = incoming.clone();
465                next.keep_fired(stored);
466                *stored = next;
467                true
468            })
469            .await
470            .unwrap()
471            .unwrap();
472
473        assert_eq!(attempts, 2);
474        assert_eq!(saved.watch_once[0].fired_at, Some(finish.at));
475        assert_eq!(
476            repository.notification_preferences().await.unwrap()[&device.id],
477            saved
478        );
479        assert_eq!(
480            repository
481                .update_notification_preferences("unknown", |_| true)
482                .await
483                .unwrap(),
484            None
485        );
486    }
487}