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