1use std::path::Path;
12use std::sync::{Mutex, MutexGuard};
13
14use rusqlite::{params, Connection, OptionalExtension};
15
16use crate::error::{Result, TrendError};
17use crate::model::{Observation, RepoListed, SourceKind, SubjectRecord, VelocityRow};
18use crate::score;
19
20pub const TOPIC_SEP: char = '\u{0}';
22
23pub const SCHEMA_VERSION: i64 = 2;
25
26#[derive(Debug)]
33pub struct Store {
34 conn: Mutex<Connection>,
35}
36
37impl Store {
38 pub fn open(path: &Path) -> Result<Store> {
43 let conn = Connection::open(path).map_err(TrendError::from)?;
44 let store = Store {
45 conn: Mutex::new(conn),
46 };
47 store.configure_and_migrate()?;
48 Ok(store)
49 }
50
51 pub fn open_in_memory() -> Result<Store> {
56 let conn = Connection::open_in_memory().map_err(TrendError::from)?;
57 let store = Store {
58 conn: Mutex::new(conn),
59 };
60 store.configure_and_migrate()?;
61 Ok(store)
62 }
63
64 pub fn from_connection(conn: Connection) -> Result<Store> {
69 let store = Store {
70 conn: Mutex::new(conn),
71 };
72 store.configure_and_migrate()?;
73 Ok(store)
74 }
75
76 fn lock(&self) -> Result<MutexGuard<'_, Connection>> {
78 self.conn
79 .lock()
80 .map_err(|_| TrendError::Other("store mutex poisoned".to_owned()))
81 }
82
83 pub fn with_conn<T>(
91 &self,
92 f: impl FnOnce(&Connection) -> std::result::Result<T, rusqlite::Error>,
93 ) -> Result<T> {
94 let conn = self.lock()?;
95 f(&conn).map_err(TrendError::from)
96 }
97
98 fn configure_and_migrate(&self) -> Result<()> {
99 let conn = self.lock()?;
100 conn.pragma_update(None, "journal_mode", "WAL")
101 .map_err(TrendError::from)?;
102 conn.pragma_update(None, "foreign_keys", "ON")
103 .map_err(TrendError::from)?;
104
105 let version: i64 = conn
106 .query_row("PRAGMA user_version", [], |row| row.get(0))
107 .map_err(TrendError::from)?;
108 if version > SCHEMA_VERSION {
109 return Err(TrendError::Config(format!(
110 "database schema v{version} is newer than this binary (v{SCHEMA_VERSION})"
111 )));
112 }
113 if version < SCHEMA_VERSION {
114 conn.execute_batch(
116 "DROP TABLE IF EXISTS snapshots;
117 DROP TABLE IF EXISTS repos;",
118 )
119 .map_err(TrendError::from)?;
120 }
121
122 conn.execute_batch(
123 "
124 CREATE TABLE IF NOT EXISTS subjects (
125 subject_id INTEGER PRIMARY KEY,
126 kind TEXT NOT NULL,
127 key TEXT NOT NULL,
128 title TEXT,
129 lang TEXT,
130 url TEXT,
131 topics TEXT NOT NULL DEFAULT '',
132 created_at TEXT,
133 UNIQUE(kind, key)
134 );
135 CREATE TABLE IF NOT EXISTS observations (
136 subject_id INTEGER NOT NULL REFERENCES subjects(subject_id),
137 ts INTEGER NOT NULL,
138 score INTEGER NOT NULL,
139 extra1 INTEGER NOT NULL DEFAULT 0,
140 extra2 INTEGER NOT NULL DEFAULT 0,
141 PRIMARY KEY (subject_id, ts)
142 );
143 CREATE TABLE IF NOT EXISTS profile_repos (
144 full_name TEXT PRIMARY KEY,
145 language TEXT,
146 topics TEXT NOT NULL DEFAULT '',
147 description TEXT,
148 is_fork INTEGER NOT NULL DEFAULT 0,
149 pushed_at TEXT,
150 stars INTEGER NOT NULL DEFAULT 0
151 );
152 CREATE TABLE IF NOT EXISTS collect_runs (
153 run_id INTEGER PRIMARY KEY,
154 ts INTEGER NOT NULL,
155 source TEXT NOT NULL,
156 pages INTEGER NOT NULL,
157 items INTEGER NOT NULL
158 );
159 ",
160 )
161 .map_err(TrendError::from)?;
162 if version < SCHEMA_VERSION {
163 conn.pragma_update(None, "user_version", SCHEMA_VERSION)
164 .map_err(TrendError::from)?;
165 }
166 Ok(())
167 }
168
169 pub fn upsert_subject(&self, subject: &SubjectRecord) -> Result<i64> {
177 let sep = TOPIC_SEP.to_string();
178 let topics = subject.topics.join(&sep);
179 let conn = self.lock()?;
180 conn.query_row(
181 "
182 INSERT INTO subjects (kind, key, title, lang, url, topics, created_at)
183 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
184 ON CONFLICT(kind, key) DO UPDATE SET
185 title = COALESCE(excluded.title, subjects.title),
186 lang = COALESCE(excluded.lang, subjects.lang),
187 url = COALESCE(excluded.url, subjects.url),
188 topics = CASE WHEN excluded.topics != '' THEN excluded.topics ELSE subjects.topics END,
189 created_at = COALESCE(excluded.created_at, subjects.created_at)
190 RETURNING subject_id
191 ",
192 params![
193 subject.kind.as_str(),
194 subject.key,
195 subject.title,
196 subject.lang,
197 subject.url,
198 topics,
199 subject.created_at,
200 ],
201 |row| row.get(0),
202 )
203 .map_err(TrendError::from)
204 }
205
206 pub fn insert_observation(&self, subject_id: i64, obs: &Observation) -> Result<()> {
211 let conn = self.lock()?;
212 conn.execute(
213 "
214 INSERT OR REPLACE INTO observations (subject_id, ts, score, extra1, extra2)
215 VALUES (?1, ?2, ?3, ?4, ?5)
216 ",
217 params![subject_id, obs.ts, obs.score, obs.extra1, obs.extra2],
218 )
219 .map_err(TrendError::from)?;
220 Ok(())
221 }
222
223 pub fn record_run(&self, ts: i64, source: &str, pages: i64, items: i64) -> Result<()> {
228 let conn = self.lock()?;
229 conn.execute(
230 "INSERT INTO collect_runs (ts, source, pages, items) VALUES (?1, ?2, ?3, ?4)",
231 params![ts, source, pages, items],
232 )
233 .map_err(TrendError::from)?;
234 Ok(())
235 }
236
237 pub fn last_run(&self) -> Result<Option<(i64, String, i64)>> {
242 let conn = self.lock()?;
243 conn.query_row(
244 "SELECT ts, source, items FROM collect_runs ORDER BY run_id DESC LIMIT 1",
245 [],
246 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
247 )
248 .optional()
249 .map_err(TrendError::from)
250 }
251
252 pub fn counts(&self) -> Result<(i64, i64)> {
257 let conn = self.lock()?;
258 let subjects: i64 = conn
259 .query_row("SELECT COUNT(*) FROM subjects", [], |row| row.get(0))
260 .map_err(TrendError::from)?;
261 let observations: i64 = conn
262 .query_row("SELECT COUNT(*) FROM observations", [], |row| row.get(0))
263 .map_err(TrendError::from)?;
264 Ok((subjects, observations))
265 }
266
267 pub fn velocity_rows(&self, window_days: u32, limit: u32) -> Result<Vec<VelocityRow>> {
276 let window_secs = i64::from(window_days) * 86_400;
277 let conn = self.lock()?;
278 let mut stmt = conn
279 .prepare(
280 "
281 WITH latest AS (
282 SELECT subject_id, MAX(ts) AS ts FROM observations GROUP BY subject_id
283 ),
284 earliest AS (
285 SELECT o.subject_id, MIN(o.ts) AS ts
286 FROM observations o
287 JOIN latest l ON l.subject_id = o.subject_id
288 WHERE o.ts >= l.ts - ?1
289 GROUP BY o.subject_id
290 )
291 SELECT s.kind, s.key, s.title, s.lang, s.url, s.topics,
292 lo.score, eo.score, (lo.ts - eo.ts)
293 FROM latest l
294 JOIN earliest e ON e.subject_id = l.subject_id
295 JOIN observations lo ON lo.subject_id = l.subject_id AND lo.ts = l.ts
296 JOIN observations eo ON eo.subject_id = e.subject_id AND eo.ts = e.ts
297 JOIN subjects s ON s.subject_id = l.subject_id
298 ORDER BY (lo.score - eo.score) * 1.0 / MAX((lo.ts - eo.ts), 3600) DESC
299 LIMIT ?2
300 ",
301 )
302 .map_err(TrendError::from)?;
303
304 let rows = stmt
305 .query_map(params![window_secs, i64::from(limit)], |row| {
306 let kind_raw: String = row.get(0)?;
307 let key: String = row.get(1)?;
308 let title: Option<String> = row.get(2)?;
309 let lang: Option<String> = row.get(3)?;
310 let url: Option<String> = row.get(4)?;
311 let topics_raw: String = row.get(5)?;
312 let score_now: i64 = row.get(6)?;
313 let score_prev: i64 = row.get(7)?;
314 let window: i64 = row.get(8)?;
315 Ok(VelocityRow {
316 kind: SourceKind::parse(&kind_raw).unwrap_or(SourceKind::GithubRepo),
317 key,
318 title,
319 lang,
320 url,
321 topics: split_topics(&topics_raw),
322 score_now,
323 score_prev,
324 window_secs: window,
325 velocity_per_day: score::velocity_per_day(score_prev, score_now, window),
326 })
327 })
328 .map_err(TrendError::from)?;
329
330 let mut out = Vec::new();
331 for row in rows {
332 out.push(row.map_err(TrendError::from)?);
333 }
334 Ok(out)
335 }
336
337 pub fn save_profile_rows(&self, rows: &[RepoListed]) -> Result<()> {
342 let conn = self.lock()?;
343 conn.execute("DELETE FROM profile_repos", [])
344 .map_err(TrendError::from)?;
345 for r in rows {
346 let topics = r.topics.join(&TOPIC_SEP.to_string());
347 conn.execute(
348 "
349 INSERT INTO profile_repos (full_name, language, topics, description, is_fork, pushed_at, stars)
350 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
351 ",
352 params![
353 r.full_name,
354 r.language,
355 topics,
356 r.description,
357 i64::from(r.is_fork),
358 r.pushed_at,
359 r.stars,
360 ],
361 )
362 .map_err(TrendError::from)?;
363 }
364 Ok(())
365 }
366
367 pub fn load_profile_rows(&self) -> Result<Vec<RepoListed>> {
372 let conn = self.lock()?;
373 let mut stmt = conn
374 .prepare(
375 "SELECT full_name, language, topics, description, is_fork, pushed_at, stars
376 FROM profile_repos ORDER BY full_name",
377 )
378 .map_err(TrendError::from)?;
379 let rows = stmt
380 .query_map([], |row| {
381 let full_name: String = row.get(0)?;
382 let language: Option<String> = row.get(1)?;
383 let topics_raw: String = row.get(2)?;
384 let description: Option<String> = row.get(3)?;
385 let is_fork: i64 = row.get(4)?;
386 let pushed_at: Option<String> = row.get(5)?;
387 let stars: i64 = row.get(6)?;
388 Ok(RepoListed {
389 full_name,
390 language,
391 topics: split_topics(&topics_raw),
392 description,
393 is_fork: is_fork != 0,
394 pushed_at,
395 stars,
396 })
397 })
398 .map_err(TrendError::from)?;
399
400 let mut out = Vec::new();
401 for row in rows {
402 out.push(row.map_err(TrendError::from)?);
403 }
404 Ok(out)
405 }
406}
407
408fn split_topics(raw: &str) -> Vec<String> {
410 if raw.is_empty() {
411 Vec::new()
412 } else {
413 raw.split(TOPIC_SEP).map(str::to_owned).collect()
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
420
421 use super::*;
422
423 fn subject(kind: SourceKind, key: &str, lang: Option<&str>, topics: &[&str]) -> SubjectRecord {
424 SubjectRecord {
425 kind,
426 key: key.to_owned(),
427 title: Some("desc".to_owned()),
428 lang: lang.map(str::to_owned),
429 url: None,
430 topics: topics.iter().map(|t| (*t).to_owned()).collect(),
431 created_at: None,
432 }
433 }
434
435 fn obs(ts: i64, score: i64) -> Observation {
436 Observation {
437 ts,
438 score,
439 extra1: 1,
440 extra2: 1,
441 }
442 }
443
444 #[test]
445 fn upsert_is_idempotent_on_kind_and_key() {
446 let store = Store::open_in_memory().unwrap();
447 let id1 = store
448 .upsert_subject(&subject(
449 SourceKind::GithubRepo,
450 "a/b",
451 Some("Rust"),
452 &["cli"],
453 ))
454 .unwrap();
455 let id2 = store
456 .upsert_subject(&subject(
457 SourceKind::GithubRepo,
458 "a/b",
459 Some("Rust"),
460 &["cli"],
461 ))
462 .unwrap();
463 assert_eq!(id1, id2);
464 let id3 = store
466 .upsert_subject(&subject(SourceKind::Crate, "a/b", None, &[]))
467 .unwrap();
468 assert_ne!(id1, id3);
469 let (subjects, observations) = store.counts().unwrap();
470 assert_eq!(subjects, 2);
471 assert_eq!(observations, 0);
472 }
473
474 #[test]
475 fn upsert_preserves_richer_fields() {
476 let store = Store::open_in_memory().unwrap();
477 let rich = SubjectRecord {
478 kind: SourceKind::GithubRepo,
479 key: "a/b".to_owned(),
480 title: Some("full description".to_owned()),
481 lang: Some("Rust".to_owned()),
482 url: Some("https://github.com/a/b".to_owned()),
483 topics: vec!["cli".to_owned()],
484 created_at: Some("2026-10-01T00:00:00Z".to_owned()),
485 };
486 store.upsert_subject(&rich).unwrap();
487 let sparse = SubjectRecord {
488 kind: SourceKind::GithubRepo,
489 key: "a/b".to_owned(),
490 title: None,
491 lang: None,
492 url: None,
493 topics: vec![],
494 created_at: None,
495 };
496 store.upsert_subject(&sparse).unwrap();
497 let rows = store.velocity_rows(14, 10).unwrap();
498 assert_eq!(rows.len(), 0); }
500
501 #[test]
502 fn velocity_ranks_fastest_first_across_kinds() {
503 let store = Store::open_in_memory().unwrap();
504 let slow = store
505 .upsert_subject(&subject(SourceKind::GithubRepo, "s/slow", Some("Go"), &[]))
506 .unwrap();
507 let fast = store
508 .upsert_subject(&subject(SourceKind::HnStory, "42", None, &[]))
509 .unwrap();
510 store.insert_observation(slow, &obs(0, 100)).unwrap();
511 store.insert_observation(slow, &obs(86_400, 110)).unwrap(); store.insert_observation(fast, &obs(0, 100)).unwrap();
513 store.insert_observation(fast, &obs(86_400, 170)).unwrap(); let rows = store.velocity_rows(14, 10).unwrap();
516 assert_eq!(rows.len(), 2);
517 assert_eq!(rows[0].kind, SourceKind::HnStory);
518 assert_eq!(rows[0].key, "42");
519 assert!((rows[0].velocity_per_day - 70.0).abs() < 1e-9);
520 assert!((rows[1].velocity_per_day - 10.0).abs() < 1e-9);
521 }
522
523 #[test]
524 fn single_observation_reports_zero_velocity() {
525 let store = Store::open_in_memory().unwrap();
526 let id = store
527 .upsert_subject(&subject(
528 SourceKind::Crate,
529 "serde",
530 None,
531 &["serialization"],
532 ))
533 .unwrap();
534 store.insert_observation(id, &obs(86_400, 5_000)).unwrap();
535 let rows = store.velocity_rows(14, 10).unwrap();
536 assert_eq!(rows.len(), 1);
537 assert!((rows[0].velocity_per_day - 0.0).abs() < 1e-9);
538 assert_eq!(rows[0].score_now, 5_000);
539 assert_eq!(rows[0].score_prev, 5_000);
540 assert_eq!(rows[0].topics, vec!["serialization".to_owned()]);
541 }
542
543 #[test]
544 fn profile_roundtrip_preserves_topics() {
545 let store = Store::open_in_memory().unwrap();
546 let rows = vec![RepoListed {
547 full_name: "WyattAu/crawlkit".to_owned(),
548 language: Some("Rust".to_owned()),
549 topics: vec!["crawler".to_owned(), "seo".to_owned()],
550 description: None,
551 is_fork: false,
552 pushed_at: None,
553 stars: 42,
554 }];
555 store.save_profile_rows(&rows).unwrap();
556 let loaded = store.load_profile_rows().unwrap();
557 assert_eq!(loaded, rows);
558 }
559
560 #[test]
561 fn runs_are_recorded_and_latest_read_back() {
562 let store = Store::open_in_memory().unwrap();
563 assert!(store.last_run().unwrap().is_none());
564 store.record_run(1, "github", 3, 300).unwrap();
565 store.record_run(2, "hn", 2, 200).unwrap();
566 let Some((ts, source, items)) = store.last_run().unwrap() else {
567 panic!("expected a recorded run");
568 };
569 assert_eq!((ts, source.as_str(), items), (2, "hn", 200));
570 }
571
572 #[test]
573 fn legacy_v1_schema_is_migrated() {
574 let conn = Connection::open_in_memory().unwrap();
576 conn.execute_batch(
577 "
578 CREATE TABLE repos (
579 repo_id INTEGER PRIMARY KEY,
580 full_name TEXT NOT NULL UNIQUE,
581 owner TEXT NOT NULL,
582 name TEXT NOT NULL,
583 description TEXT,
584 language TEXT,
585 html_url TEXT,
586 created_at TEXT,
587 topics TEXT NOT NULL DEFAULT ''
588 );
589 CREATE TABLE snapshots (
590 repo_id INTEGER NOT NULL REFERENCES repos(repo_id),
591 ts INTEGER NOT NULL,
592 stars INTEGER NOT NULL,
593 forks INTEGER NOT NULL,
594 watchers INTEGER NOT NULL,
595 open_issues INTEGER NOT NULL,
596 PRIMARY KEY (repo_id, ts)
597 );
598 INSERT INTO repos (repo_id, full_name, owner, name) VALUES (1, 'old/row', 'old', 'row');
599 INSERT INTO snapshots (repo_id, ts, stars, forks, watchers, open_issues)
600 VALUES (1, 1, 1, 0, 0, 0);
601 ",
602 )
603 .unwrap();
604
605 let store = Store::from_connection(conn).unwrap();
606 let (subjects, observations) = store.counts().unwrap();
608 assert_eq!((subjects, observations), (0, 0));
609 let id = store
610 .upsert_subject(&subject(SourceKind::GithubRepo, "new/row", None, &[]))
611 .unwrap();
612 store.insert_observation(id, &obs(1, 1)).unwrap();
613 let version: i64 = store
614 .lock()
615 .unwrap()
616 .query_row("PRAGMA user_version", [], |r| r.get(0))
617 .unwrap();
618 assert_eq!(version, SCHEMA_VERSION);
619 }
620
621 #[test]
622 fn newer_schema_is_rejected() {
623 let conn = Connection::open_in_memory().unwrap();
624 conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
625 .unwrap();
626 let result = Store::from_connection(conn);
627 assert!(result.is_err());
628 }
629}