Skip to main content

client_core/store/
queries.rs

1use super::models::{AlertPref, RetentionPref, SourceRow, StoredClip, StoredDevice};
2use super::{Store, StoreError};
3use rusqlite::params;
4use rusqlite::OptionalExtension;
5
6pub fn insert_clip(store: &Store, c: &StoredClip) -> Result<(), StoreError> {
7    store.with_conn(|conn| {
8        conn.execute(
9            r#"INSERT OR REPLACE INTO clips
10               (id, source, source_key, content_type, content, media_path, byte_size, created_at, pinned, pinned_at, synced)
11               VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#,
12            params![
13                c.id, c.source, c.source_key, c.content_type, c.content,
14                c.media_path, c.byte_size, c.created_at,
15                if c.pinned { 1i64 } else { 0 }, c.pinned_at,
16                if c.synced { 1i64 } else { 0 },
17            ],
18        )?;
19        Ok(())
20    })
21}
22
23pub fn list_clips(
24    store: &Store,
25    from: Option<&str>,
26    limit: Option<i64>,
27    since_ms: Option<i64>,
28    pinned_only: bool,
29    default_limit: i64,
30) -> Result<Vec<StoredClip>, StoreError> {
31    let mut sql = String::from(
32        "SELECT id, source, source_key, content_type, content, media_path, byte_size, created_at, pinned, pinned_at, synced
33         FROM clips WHERE 1=1"
34    );
35    let mut binds: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
36    if let Some(s) = from {
37        sql.push_str(" AND source = ?");
38        binds.push(Box::new(s.to_string()));
39    }
40    if let Some(t) = since_ms {
41        sql.push_str(" AND created_at >= ?");
42        binds.push(Box::new(t));
43    }
44    if pinned_only {
45        sql.push_str(" AND pinned = 1");
46    }
47    sql.push_str(" ORDER BY created_at DESC LIMIT ?");
48    binds.push(Box::new(limit.unwrap_or(default_limit)));
49
50    store.with_conn(|conn| {
51        let mut stmt = conn.prepare(&sql)?;
52        let rows: Vec<StoredClip> = stmt
53            .query_map(
54                rusqlite::params_from_iter(binds.iter().map(|b| &**b as &dyn rusqlite::ToSql)),
55                |r| {
56                    Ok(StoredClip {
57                        id: r.get(0)?,
58                        source: r.get(1)?,
59                        source_key: r.get(2)?,
60                        content_type: r.get(3)?,
61                        content: r.get(4)?,
62                        media_path: r.get(5)?,
63                        byte_size: r.get(6)?,
64                        created_at: r.get(7)?,
65                        pinned: r.get::<_, i64>(8)? != 0,
66                        pinned_at: r.get(9)?,
67                        synced: r.get::<_, i64>(10)? != 0,
68                    })
69                },
70            )?
71            .filter_map(|r| r.ok())
72            .collect();
73        Ok(rows)
74    })
75}
76
77pub fn get_clip(store: &Store, id: &str) -> Result<Option<StoredClip>, StoreError> {
78    store.with_conn(|conn| {
79        let mut stmt = conn.prepare(
80            "SELECT id, source, source_key, content_type, content, media_path, byte_size, created_at, pinned, pinned_at, synced
81             FROM clips WHERE id = ?1"
82        )?;
83        let mut rows = stmt.query_map(params![id], |r| Ok(StoredClip {
84            id: r.get(0)?, source: r.get(1)?, source_key: r.get(2)?,
85            content_type: r.get(3)?, content: r.get(4)?, media_path: r.get(5)?,
86            byte_size: r.get(6)?, created_at: r.get(7)?,
87            pinned: r.get::<_, i64>(8)? != 0, pinned_at: r.get(9)?,
88            synced: r.get::<_, i64>(10)? != 0,
89        }))?;
90        if let Some(row) = rows.next() { Ok(Some(row?)) } else { Ok(None) }
91    })
92}
93
94pub fn delete_clip(store: &Store, id: &str) -> Result<(), StoreError> {
95    store.with_conn(|conn| {
96        conn.execute("DELETE FROM clips WHERE id = ?1", params![id])?;
97        Ok(())
98    })
99}
100
101pub fn set_pinned(store: &Store, id: &str, pinned: bool, when_ms: i64) -> Result<(), StoreError> {
102    store.with_conn(|conn| {
103        conn.execute(
104            "UPDATE clips SET pinned = ?1, pinned_at = CASE WHEN ?1 = 1 THEN ?2 ELSE NULL END WHERE id = ?3",
105            params![if pinned { 1i64 } else { 0 }, when_ms, id],
106        )?;
107        Ok(())
108    })
109}
110
111pub fn list_sources(store: &Store) -> Result<Vec<SourceRow>, StoreError> {
112    store.with_conn(|conn| {
113        let mut stmt = conn.prepare(
114            "SELECT source, COUNT(*) AS c, MAX(created_at) AS last_seen
115             FROM clips GROUP BY source ORDER BY last_seen DESC NULLS LAST",
116        )?;
117        let rows: Vec<SourceRow> = stmt
118            .query_map([], |r| {
119                Ok(SourceRow {
120                    source: r.get(0)?,
121                    clip_count: r.get(1)?,
122                    last_seen: r.get(2)?,
123                })
124            })?
125            .filter_map(|r| r.ok())
126            .collect();
127        Ok(rows)
128    })
129}
130
131pub fn upsert_device(store: &Store, d: &StoredDevice) -> Result<(), StoreError> {
132    store.with_conn(|conn| {
133        conn.execute(
134            r#"INSERT OR REPLACE INTO devices
135               (id, hostname, nickname, source_key, machine_id, public_key,
136                paired_at, last_push_at, online, refreshed_at)
137               VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"#,
138            params![
139                d.id,
140                d.hostname,
141                d.nickname,
142                d.source_key,
143                d.machine_id,
144                d.public_key,
145                d.paired_at,
146                d.last_push_at,
147                if d.online { 1i64 } else { 0 },
148                d.refreshed_at,
149            ],
150        )?;
151        Ok(())
152    })
153}
154
155pub fn list_devices(store: &Store) -> Result<Vec<StoredDevice>, StoreError> {
156    store.with_conn(|conn| {
157        let mut stmt = conn.prepare(
158            "SELECT id, hostname, nickname, source_key, machine_id, public_key,
159                    paired_at, last_push_at, online, refreshed_at
160             FROM devices ORDER BY last_push_at DESC NULLS LAST",
161        )?;
162        let rows: Vec<StoredDevice> = stmt
163            .query_map([], |r| {
164                Ok(StoredDevice {
165                    id: r.get(0)?,
166                    hostname: r.get(1)?,
167                    nickname: r.get(2)?,
168                    source_key: r.get(3)?,
169                    machine_id: r.get(4)?,
170                    public_key: r.get(5)?,
171                    paired_at: r.get(6)?,
172                    last_push_at: r.get(7)?,
173                    online: r.get::<_, i64>(8)? != 0,
174                    refreshed_at: r.get(9)?,
175                })
176            })?
177            .filter_map(|r| r.ok())
178            .collect();
179        Ok(rows)
180    })
181}
182
183pub fn set_retention(store: &Store, device_id: &str, days: i64) -> Result<(), StoreError> {
184    store.with_conn(|conn| {
185        conn.execute(
186            "INSERT INTO retention_prefs(device_id, days) VALUES(?1, ?2)
187             ON CONFLICT(device_id) DO UPDATE SET days = excluded.days",
188            params![device_id, days],
189        )?;
190        Ok(())
191    })
192}
193
194pub fn list_retention(store: &Store) -> Result<Vec<RetentionPref>, StoreError> {
195    store.with_conn(|conn| {
196        let mut stmt = conn.prepare("SELECT device_id, days FROM retention_prefs")?;
197        let rows: Vec<RetentionPref> = stmt
198            .query_map([], |r| {
199                Ok(RetentionPref {
200                    device_id: r.get(0)?,
201                    days: r.get(1)?,
202                })
203            })?
204            .filter_map(|r| r.ok())
205            .collect();
206        Ok(rows)
207    })
208}
209
210pub fn set_alert_pref(store: &Store, source: &str, enabled: bool) -> Result<(), StoreError> {
211    store.with_conn(|conn| {
212        conn.execute(
213            "INSERT INTO alert_prefs(source, enabled) VALUES(?1, ?2)
214             ON CONFLICT(source) DO UPDATE SET enabled = excluded.enabled",
215            params![source, if enabled { 1i64 } else { 0 }],
216        )?;
217        Ok(())
218    })
219}
220
221pub fn list_alert_prefs(store: &Store) -> Result<Vec<AlertPref>, StoreError> {
222    store.with_conn(|conn| {
223        let mut stmt = conn.prepare("SELECT source, enabled FROM alert_prefs")?;
224        let rows: Vec<AlertPref> = stmt
225            .query_map([], |r| {
226                Ok(AlertPref {
227                    source: r.get(0)?,
228                    enabled: r.get::<_, i64>(1)? != 0,
229                })
230            })?
231            .filter_map(|r| r.ok())
232            .collect();
233        Ok(rows)
234    })
235}
236
237pub fn watermark(store: &Store) -> Result<Option<String>, StoreError> {
238    store.with_conn(|conn| {
239        conn.query_row(
240            "SELECT value FROM meta WHERE key='last_sync_watermark'",
241            [],
242            |r| r.get::<_, String>(0),
243        )
244        .map(Some)
245        .or_else(|e| match e {
246            rusqlite::Error::QueryReturnedNoRows => Ok(None),
247            other => Err(other),
248        })
249    })
250}
251
252pub fn set_watermark(store: &Store, ulid: &str) -> Result<(), StoreError> {
253    store.with_conn(|conn| {
254        conn.execute(
255            "INSERT INTO meta(key, value) VALUES('last_sync_watermark', ?1)
256             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
257            params![ulid],
258        )?;
259        Ok(())
260    })
261}
262
263/// Return the total number of clips in the store.
264pub fn clip_count(store: &Store) -> Result<i64, StoreError> {
265    store.with_conn(|conn| conn.query_row("SELECT COUNT(*) FROM clips", [], |r| r.get::<_, i64>(0)))
266}
267
268/// Return the number of clips whose `created_at` (ms) is before `cutoff_ms`.
269/// Used by the Settings pane "preview retention change" dialog.
270pub fn count_clips_before(store: &Store, cutoff_ms: i64) -> Result<i64, StoreError> {
271    store.with_conn(|conn| {
272        conn.query_row(
273            "SELECT COUNT(*) FROM clips WHERE created_at < ?1",
274            rusqlite::params![cutoff_ms],
275            |r| r.get::<_, i64>(0),
276        )
277    })
278}
279
280/// Delete all clips from the store. Returns the number of rows deleted.
281pub fn clear_all_clips(store: &Store) -> Result<i64, StoreError> {
282    store.with_conn(|conn| {
283        let n = conn.execute("DELETE FROM clips", [])?;
284        Ok(n as i64)
285    })
286}
287
288pub fn search_clips(store: &Store, query: &str, limit: i64) -> Result<Vec<StoredClip>, StoreError> {
289    store.with_conn(|conn| {
290        let mut stmt = conn.prepare(
291            "SELECT c.id, c.source, c.source_key, c.content_type, c.content, c.media_path,
292                    c.byte_size, c.created_at, c.pinned, c.pinned_at, c.synced
293             FROM clips c JOIN clips_fts f ON f.rowid = c.rowid
294             WHERE clips_fts MATCH ?1 ORDER BY rank LIMIT ?2",
295        )?;
296        let rows: Vec<StoredClip> = stmt
297            .query_map(params![query, limit], |r| {
298                Ok(StoredClip {
299                    id: r.get(0)?,
300                    source: r.get(1)?,
301                    source_key: r.get(2)?,
302                    content_type: r.get(3)?,
303                    content: r.get(4)?,
304                    media_path: r.get(5)?,
305                    byte_size: r.get(6)?,
306                    created_at: r.get(7)?,
307                    pinned: r.get::<_, i64>(8)? != 0,
308                    pinned_at: r.get(9)?,
309                    synced: r.get::<_, i64>(10)? != 0,
310                })
311            })?
312            .filter_map(|r| r.ok())
313            .collect();
314        Ok(rows)
315    })
316}
317
318/// Return all clips that have not yet been synced to the relay, ordered oldest first.
319pub fn list_unsynced_clips(store: &Store) -> Result<Vec<StoredClip>, StoreError> {
320    store.with_conn(|conn| {
321        let mut stmt = conn.prepare(
322            "SELECT id, source, source_key, content_type, content, media_path, byte_size,
323                    created_at, pinned, pinned_at, synced
324             FROM clips
325             WHERE synced = 0
326             ORDER BY created_at ASC",
327        )?;
328        let rows: Vec<StoredClip> = stmt
329            .query_map([], |r| {
330                Ok(StoredClip {
331                    id: r.get(0)?,
332                    source: r.get(1)?,
333                    source_key: r.get(2)?,
334                    content_type: r.get(3)?,
335                    content: r.get(4)?,
336                    media_path: r.get(5)?,
337                    byte_size: r.get(6)?,
338                    created_at: r.get(7)?,
339                    pinned: r.get::<_, i64>(8)? != 0,
340                    pinned_at: r.get(9)?,
341                    synced: r.get::<_, i64>(10)? != 0,
342                })
343            })?
344            .collect::<Result<Vec<_>, _>>()?;
345        Ok(rows)
346    })
347}
348
349/// Drop the oldest unsynced clips that exceed `max`, keeping the newest `max`.
350/// Returns the number of rows deleted.
351pub fn enforce_offline_cap(store: &Store, max: usize) -> Result<usize, StoreError> {
352    store.with_conn(|conn| {
353        let count: i64 =
354            conn.query_row("SELECT COUNT(*) FROM clips WHERE synced = 0", [], |r| {
355                r.get(0)
356            })?;
357        if (count as usize) <= max {
358            return Ok(0);
359        }
360        let excess = count as usize - max;
361        let dropped = conn.execute(
362            "DELETE FROM clips
363             WHERE id IN (
364                 SELECT id FROM clips WHERE synced = 0 ORDER BY created_at ASC LIMIT ?1
365             )",
366            params![excess as i64],
367        )?;
368        log::warn!(
369            "offline queue cap: dropped {} oldest unsynced clips (cap={})",
370            dropped,
371            max
372        );
373        Ok(dropped)
374    })
375}
376/// Rename a clip's id (e.g. temp local id → relay-assigned ULID) and mark it synced.
377/// Returns the number of rows updated (0 if the old id was not found).
378pub fn replace_id_and_mark_synced(
379    store: &Store,
380    old_id: &str,
381    new_id: &str,
382) -> Result<usize, StoreError> {
383    store.with_conn(|conn| {
384        let n = conn.execute(
385            "UPDATE clips SET id = ?1, synced = 1 WHERE id = ?2",
386            params![new_id, old_id],
387        )?;
388        Ok(n)
389    })
390}
391const LAST_FLUSH_KEY: &str = "backlog.last_flush_at";
392
393/// Read the epoch-millisecond timestamp of the last successful backlog flush.
394/// Returns `None` if no flush has ever been recorded.
395pub fn get_last_flush_at(store: &Store) -> Result<Option<i64>, StoreError> {
396    store.with_conn(|conn| {
397        let v: Option<String> = conn
398            .query_row(
399                "SELECT value FROM meta WHERE key = ?1",
400                params![LAST_FLUSH_KEY],
401                |r| r.get(0),
402            )
403            .optional()?;
404        Ok(v.and_then(|s| s.parse().ok()))
405    })
406}
407
408/// Persist the epoch-millisecond timestamp of a successful backlog flush.
409pub fn set_last_flush_at(store: &Store, ts: i64) -> Result<(), StoreError> {
410    store.with_conn(|conn| {
411        conn.execute(
412            "INSERT INTO meta(key, value) VALUES(?1, ?2)
413             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
414            params![LAST_FLUSH_KEY, ts.to_string()],
415        )?;
416        Ok(())
417    })
418}
419#[cfg(test)]
420mod tests {
421    use super::super::models::StoredClip;
422    use super::*;
423
424    #[test]
425    fn insert_clip_persists_synced_false() {
426        let store = Store::open(std::path::Path::new(":memory:")).unwrap();
427        let clip = StoredClip {
428            id: "01HXABC".into(),
429            source: "atlas0".into(),
430            source_key: None,
431            content_type: "text".into(),
432            content: Some(b"hello".to_vec()),
433            media_path: None,
434            byte_size: 5,
435            created_at: 1_700_000_000_000,
436            pinned: false,
437            pinned_at: None,
438            synced: false,
439        };
440        insert_clip(&store, &clip).unwrap();
441        let row = get_clip(&store, &clip.id).unwrap().unwrap();
442        assert!(
443            !row.synced,
444            "synced=false must survive an insert/read round-trip"
445        );
446    }
447
448    // ── Task 5: list_unsynced_clips ──────────────────────────────────────────
449
450    #[test]
451    fn list_unsynced_clips_returns_oldest_first() {
452        let store = Store::open(std::path::Path::new(":memory:")).unwrap();
453        fn make(id: &str, ts: i64, synced: bool) -> StoredClip {
454            StoredClip {
455                id: id.into(),
456                source: "s".into(),
457                source_key: None,
458                content_type: "text".into(),
459                content: Some(b"x".to_vec()),
460                media_path: None,
461                byte_size: 1,
462                created_at: ts,
463                pinned: false,
464                pinned_at: None,
465                synced,
466            }
467        }
468        for c in [
469            make("a", 30, true),
470            make("b", 10, false),
471            make("c", 20, false),
472        ] {
473            insert_clip(&store, &c).unwrap();
474        }
475        let rows = list_unsynced_clips(&store).unwrap();
476        let ids: Vec<&str> = rows.iter().map(|c| c.id.as_str()).collect();
477        assert_eq!(ids, vec!["b", "c"]);
478    }
479
480    // ── Task 6: enforce_offline_cap ─────────────────────────────────────────
481
482    #[test]
483    fn enforce_offline_cap_drops_oldest_unsynced() {
484        let store = Store::open(std::path::Path::new(":memory:")).unwrap();
485        for (id, ts) in [("a", 10i64), ("b", 20), ("c", 30)] {
486            let c = StoredClip {
487                id: id.into(),
488                source: "s".into(),
489                source_key: None,
490                content_type: "text".into(),
491                content: Some(b"x".to_vec()),
492                media_path: None,
493                byte_size: 1,
494                created_at: ts,
495                pinned: false,
496                pinned_at: None,
497                synced: false,
498            };
499            insert_clip(&store, &c).unwrap();
500        }
501        let dropped = enforce_offline_cap(&store, 2).unwrap();
502        assert_eq!(dropped, 1);
503        let remaining = list_unsynced_clips(&store).unwrap();
504        let ids: Vec<&str> = remaining.iter().map(|c| c.id.as_str()).collect();
505        assert_eq!(ids, vec!["b", "c"]);
506    }
507
508    #[test]
509    fn enforce_offline_cap_is_noop_when_under_cap() {
510        let store = Store::open(std::path::Path::new(":memory:")).unwrap();
511        let c = StoredClip {
512            id: "a".into(),
513            source: "s".into(),
514            source_key: None,
515            content_type: "text".into(),
516            content: Some(b"x".to_vec()),
517            media_path: None,
518            byte_size: 1,
519            created_at: 0,
520            pinned: false,
521            pinned_at: None,
522            synced: false,
523        };
524        insert_clip(&store, &c).unwrap();
525        let dropped = enforce_offline_cap(&store, 10).unwrap();
526        assert_eq!(dropped, 0);
527    }
528
529    // ── Task 7: replace_id_and_mark_synced ──────────────────────────────────
530
531    #[test]
532    fn replace_id_and_mark_synced_swaps_id_and_flag() {
533        let store = Store::open(std::path::Path::new(":memory:")).unwrap();
534        let c = StoredClip {
535            id: "local-01H".into(),
536            source: "s".into(),
537            source_key: None,
538            content_type: "text".into(),
539            content: Some(b"x".to_vec()),
540            media_path: None,
541            byte_size: 1,
542            created_at: 0,
543            pinned: false,
544            pinned_at: None,
545            synced: false,
546        };
547        insert_clip(&store, &c).unwrap();
548        let n = replace_id_and_mark_synced(&store, "local-01H", "01HRELAYID").unwrap();
549        assert_eq!(n, 1);
550        assert!(get_clip(&store, "local-01H").unwrap().is_none());
551        let after = get_clip(&store, "01HRELAYID").unwrap().unwrap();
552        assert!(after.synced);
553    }
554
555    #[test]
556    fn replace_id_and_mark_synced_is_benign_when_row_missing() {
557        let store = Store::open(std::path::Path::new(":memory:")).unwrap();
558        let n = replace_id_and_mark_synced(&store, "local-gone", "01HNEW").unwrap();
559        assert_eq!(n, 0);
560    }
561
562    // ── Task 8: get_last_flush_at / set_last_flush_at ───────────────────────
563
564    #[test]
565    fn last_flush_at_roundtrips() {
566        let store = Store::open(std::path::Path::new(":memory:")).unwrap();
567        assert!(get_last_flush_at(&store).unwrap().is_none());
568        set_last_flush_at(&store, 1_700_000_000).unwrap();
569        assert_eq!(get_last_flush_at(&store).unwrap(), Some(1_700_000_000));
570        set_last_flush_at(&store, 1_700_001_000).unwrap();
571        assert_eq!(get_last_flush_at(&store).unwrap(), Some(1_700_001_000));
572    }
573}