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
263pub 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
268pub 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
280pub 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
318pub 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
349pub 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}
376pub 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
393pub 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
408pub 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 #[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 #[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 #[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 #[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}