Skip to main content

sediment/
access.rs

1//! Access tracking for memory decay scoring.
2//!
3//! Uses a SQLite sidecar database to track access counts and timestamps,
4//! enabling freshness and frequency-based scoring without modifying LanceDB.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use rusqlite::{Connection, params};
10
11use crate::error::{Result, SedimentError};
12
13/// Record of access history for a single item.
14#[derive(Debug, Clone)]
15pub struct AccessRecord {
16    pub access_count: u32,
17    pub last_accessed_at: i64,
18    pub created_at: i64,
19}
20
21/// Tracks item access history in SQLite for decay scoring.
22pub struct AccessTracker {
23    conn: Connection,
24}
25
26impl AccessTracker {
27    /// Open or create the access tracking database.
28    pub fn open(path: &Path) -> Result<Self> {
29        let conn = Connection::open(path).map_err(|e| {
30            SedimentError::Database(format!("Failed to open access database: {}", e))
31        })?;
32
33        if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") {
34            tracing::warn!("Failed to set SQLite PRAGMAs (access): {}", e);
35        }
36
37        conn.execute_batch(
38            "CREATE TABLE IF NOT EXISTS access_log (
39                item_id TEXT PRIMARY KEY,
40                access_count INTEGER NOT NULL DEFAULT 0,
41                last_accessed_at INTEGER NOT NULL,
42                created_at INTEGER NOT NULL
43            );",
44        )
45        .map_err(|e| {
46            SedimentError::Database(format!("Failed to create access_log table: {}", e))
47        })?;
48
49        // Idempotent schema migration: add validation_count column
50        if let Err(e) = conn.execute_batch(
51            "ALTER TABLE access_log ADD COLUMN validation_count INTEGER NOT NULL DEFAULT 0;",
52        ) {
53            let msg = e.to_string();
54            if !msg.contains("duplicate column") {
55                tracing::warn!("access_log migration unexpected error: {}", msg);
56            }
57        }
58
59        Ok(Self { conn })
60    }
61
62    /// Record an access for an item. If no record exists, creates one with the given created_at.
63    pub fn record_access(&self, item_id: &str, created_at: i64) -> Result<()> {
64        let now = chrono::Utc::now().timestamp();
65        self.conn
66            .execute(
67                "INSERT INTO access_log (item_id, access_count, last_accessed_at, created_at)
68                 VALUES (?1, 1, ?2, ?3)
69                 ON CONFLICT(item_id) DO UPDATE SET
70                     access_count = access_count + 1,
71                     last_accessed_at = ?2",
72                params![item_id, now, created_at],
73            )
74            .map_err(|e| SedimentError::Database(format!("Failed to record access: {}", e)))?;
75        Ok(())
76    }
77
78    /// Record a validation (replace/confirm) for an item.
79    pub fn record_validation(&self, item_id: &str, created_at: i64) -> Result<()> {
80        let now = chrono::Utc::now().timestamp();
81        self.conn
82            .execute(
83                "INSERT INTO access_log (item_id, access_count, last_accessed_at, created_at, validation_count)
84                 VALUES (?1, 0, ?2, ?3, 1)
85                 ON CONFLICT(item_id) DO UPDATE SET
86                     validation_count = validation_count + 1,
87                     last_accessed_at = ?2",
88                params![item_id, now, created_at],
89            )
90            .map_err(|e| {
91                SedimentError::Database(format!("Failed to record validation: {}", e))
92            })?;
93        Ok(())
94    }
95
96    /// Get validation count for an item.
97    pub fn get_validation_count(&self, item_id: &str) -> Result<u32> {
98        let count: u32 = self
99            .conn
100            .query_row(
101                "SELECT COALESCE(validation_count, 0) FROM access_log WHERE item_id = ?1",
102                params![item_id],
103                |row| row.get(0),
104            )
105            .unwrap_or(0);
106        Ok(count)
107    }
108
109    /// Get access records for a batch of item IDs.
110    pub fn get_accesses(&self, item_ids: &[&str]) -> Result<HashMap<String, AccessRecord>> {
111        if item_ids.is_empty() {
112            return Ok(HashMap::new());
113        }
114
115        let placeholders: Vec<String> = item_ids
116            .iter()
117            .enumerate()
118            .map(|(i, _)| format!("?{}", i + 1))
119            .collect();
120        let sql = format!(
121            "SELECT item_id, access_count, last_accessed_at, created_at FROM access_log WHERE item_id IN ({})",
122            placeholders.join(", ")
123        );
124
125        let mut stmt = self
126            .conn
127            .prepare(&sql)
128            .map_err(|e| SedimentError::Database(format!("Failed to prepare query: {}", e)))?;
129
130        let params: Vec<&dyn rusqlite::types::ToSql> = item_ids
131            .iter()
132            .map(|id| id as &dyn rusqlite::types::ToSql)
133            .collect();
134
135        let rows = stmt
136            .query_map(params.as_slice(), |row| {
137                Ok((
138                    row.get::<_, String>(0)?,
139                    AccessRecord {
140                        access_count: row.get::<_, u32>(1)?,
141                        last_accessed_at: row.get::<_, i64>(2)?,
142                        created_at: row.get::<_, i64>(3)?,
143                    },
144                ))
145            })
146            .map_err(|e| SedimentError::Database(format!("Failed to query accesses: {}", e)))?;
147
148        let mut map = HashMap::new();
149        for row in rows {
150            let (id, record) = row.map_err(|e| {
151                SedimentError::Database(format!("Failed to read access record: {}", e))
152            })?;
153            map.insert(id, record);
154        }
155
156        Ok(map)
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use tempfile::NamedTempFile;
164
165    #[test]
166    fn test_open_creates_table() {
167        let tmp = NamedTempFile::new().unwrap();
168        let tracker = AccessTracker::open(tmp.path()).unwrap();
169        // Should not error on second open
170        drop(tracker);
171        let _tracker2 = AccessTracker::open(tmp.path()).unwrap();
172    }
173
174    #[test]
175    fn test_record_and_get_access() {
176        let tmp = NamedTempFile::new().unwrap();
177        let tracker = AccessTracker::open(tmp.path()).unwrap();
178
179        let created = 1700000000i64;
180        tracker.record_access("item1", created).unwrap();
181        tracker.record_access("item1", created).unwrap();
182        tracker.record_access("item2", created).unwrap();
183
184        let records = tracker.get_accesses(&["item1", "item2", "item3"]).unwrap();
185
186        assert_eq!(records.len(), 2);
187        assert_eq!(records["item1"].access_count, 2);
188        assert_eq!(records["item1"].created_at, created);
189        assert_eq!(records["item2"].access_count, 1);
190        assert!(!records.contains_key("item3"));
191    }
192
193    #[test]
194    fn test_get_accesses_empty() {
195        let tmp = NamedTempFile::new().unwrap();
196        let tracker = AccessTracker::open(tmp.path()).unwrap();
197        let records = tracker.get_accesses(&[]).unwrap();
198        assert!(records.is_empty());
199    }
200
201    #[test]
202    fn test_record_validation_on_new_item() {
203        // Fix #5: validation should be recorded on the new (replacement) item
204        let tmp = NamedTempFile::new().unwrap();
205        let tracker = AccessTracker::open(tmp.path()).unwrap();
206
207        let created = 1700000000i64;
208        // Record validation on a new item (not pre-existing)
209        tracker.record_validation("new-item", created).unwrap();
210        tracker.record_validation("new-item", created).unwrap();
211
212        let count = tracker.get_validation_count("new-item").unwrap();
213        assert_eq!(
214            count, 2,
215            "Validation count should be 2 after two record_validation calls"
216        );
217
218        // Item that was never validated should have 0
219        let count = tracker.get_validation_count("other-item").unwrap();
220        assert_eq!(count, 0);
221    }
222}