Skip to main content

trendkit_core/
store.rs

1//! SQLite snapshot store.
2//!
3//! Schema v2 is source-unified: `subjects` holds identity per
4//! `(kind, key)`, `observations` holds immutable readings keyed by
5//! `(subject_id, ts)`, and `collect_runs` records each pass. Databases
6//! created by schema v1 (milestone ① `repos`/`snapshots`) are migrated
7//! by dropping the legacy tables — v1 history is single-day and the
8//! schema landed before any real history accumulated; this is tracked in
9//! `PRAGMA user_version` and asserted by a test.
10
11use 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
20/// Topics are joined with this separator inside a single TEXT column.
21pub const TOPIC_SEP: char = '\u{0}';
22
23/// Current schema version.
24pub const SCHEMA_VERSION: i64 = 2;
25
26/// A handle to the trendkit SQLite database.
27///
28/// The connection sits behind a mutex so a [`Store`] is `Send + Sync`:
29/// sources run sequentially today, but later milestones run them
30/// concurrently against one store. Individual statements are short; no
31/// lock is held across `.await` points.
32#[derive(Debug)]
33pub struct Store {
34    conn: Mutex<Connection>,
35}
36
37impl Store {
38    /// Open (and migrate) the database at `path`.
39    ///
40    /// # Errors
41    /// Fails when the file cannot be opened or the schema cannot migrate.
42    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    /// Open an in-memory database (tests).
52    ///
53    /// # Errors
54    /// Fails when the in-memory database cannot be created or migrated.
55    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    /// Adopt an existing connection (tests: legacy-schema migration).
65    ///
66    /// # Errors
67    /// Fails when the schema cannot migrate.
68    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    /// Lock the connection; poisoning maps to a typed error.
77    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            // Schema v1 tables are superseded; drop rather than translate.
100            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    /// Insert or update a subject identity row; returns its `subject_id`.
155    ///
156    /// Non-null existing fields win over absent ones (`COALESCE`), so a
157    /// sparse source cannot blank out data a richer source contributed.
158    ///
159    /// # Errors
160    /// Propagates SQLite failures.
161    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    /// Append one observation for `subject_id`.
192    ///
193    /// # Errors
194    /// Propagates SQLite failures (including missing foreign key).
195    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    /// Record a completed collection pass.
209    ///
210    /// # Errors
211    /// Propagates SQLite failures.
212    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    /// The most recent collection pass, if any.
223    ///
224    /// # Errors
225    /// Propagates SQLite failures.
226    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    /// `(subject count, observation count)` for report headers.
238    ///
239    /// # Errors
240    /// Propagates SQLite failures.
241    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    /// Highest-velocity subjects over the last `window_days` days.
253    ///
254    /// Subjects with a single observation inside the window are included
255    /// with zero velocity rather than skipped, so early-stage databases
256    /// still show what is being tracked.
257    ///
258    /// # Errors
259    /// Propagates SQLite failures.
260    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    /// Replace the stored personal profile with `rows`.
323    ///
324    /// # Errors
325    /// Propagates SQLite failures.
326    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    /// The stored personal profile rows, if any.
353    ///
354    /// # Errors
355    /// Propagates SQLite failures.
356    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
393/// Split the internal topic-joined column back into topics.
394fn 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        // Same key, different kind = different subject.
450        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); // no observations yet; identity checked via upsert returning same id
484    }
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(); // +10/day
497        store.insert_observation(fast, &obs(0, 100)).unwrap();
498        store.insert_observation(fast, &obs(86_400, 170)).unwrap(); // +70/day
499
500        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        // Build a v1 database (repos/snapshots, user_version 0).
560        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        // v1 data is gone, v2 tables exist and work.
592        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}