Skip to main content

signalkit_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    /// Run `f` with the underlying connection — the escape hatch for
84    /// downstream analysts (e.g. `signalkit-integrity`) that need custom
85    /// queries without duplicating the schema. The connection is never
86    /// exposed across an `.await` point.
87    ///
88    /// # Errors
89    /// Propagates lock poisoning and anything `f` returns.
90    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            // Schema v1 tables are superseded; drop rather than translate.
115            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    /// Insert or update a subject identity row; returns its `subject_id`.
170    ///
171    /// Non-null existing fields win over absent ones (`COALESCE`), so a
172    /// sparse source cannot blank out data a richer source contributed.
173    ///
174    /// # Errors
175    /// Propagates SQLite failures.
176    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    /// Append one observation for `subject_id`.
207    ///
208    /// # Errors
209    /// Propagates SQLite failures (including missing foreign key).
210    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    /// Record a completed collection pass.
224    ///
225    /// # Errors
226    /// Propagates SQLite failures.
227    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    /// The most recent collection pass, if any.
238    ///
239    /// # Errors
240    /// Propagates SQLite failures.
241    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    /// `(subject count, observation count)` for report headers.
253    ///
254    /// # Errors
255    /// Propagates SQLite failures.
256    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    /// Highest-velocity subjects over the last `window_days` days.
268    ///
269    /// Subjects with a single observation inside the window are included
270    /// with zero velocity rather than skipped, so early-stage databases
271    /// still show what is being tracked.
272    ///
273    /// # Errors
274    /// Propagates SQLite failures.
275    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    /// Replace the stored personal profile with `rows`.
338    ///
339    /// # Errors
340    /// Propagates SQLite failures.
341    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    /// The stored personal profile rows, if any.
368    ///
369    /// # Errors
370    /// Propagates SQLite failures.
371    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
408/// Split the internal topic-joined column back into topics.
409fn 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        // Same key, different kind = different subject.
465        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); // no observations yet; identity checked via upsert returning same id
499    }
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(); // +10/day
512        store.insert_observation(fast, &obs(0, 100)).unwrap();
513        store.insert_observation(fast, &obs(86_400, 170)).unwrap(); // +70/day
514
515        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        // Build a v1 database (repos/snapshots, user_version 0).
575        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        // v1 data is gone, v2 tables exist and work.
607        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}