1use rusqlite::params;
2
3use crate::types::MemoryMetric;
4use crate::Result;
5
6use super::Store;
7
8impl Store {
9 pub fn log_access(&self, session_id: Option<&str>, action: &str, query: Option<&str>, memory_ids: &[i64], tokens_injected: i32) -> Result<()> {
10 let ids_json = serde_json::to_string(memory_ids).unwrap_or_default();
11 self.conn().execute(
12 "INSERT INTO access_log (session_id, action, query, memory_ids, tokens_injected) VALUES (?1, ?2, ?3, ?4, ?5)",
13 params![session_id, action, query, ids_json, tokens_injected],
14 )?;
15 Ok(())
16 }
17
18 pub fn get_session_access_log(&self, session_id: &str, limit: Option<i32>) -> Result<Vec<crate::types::AccessLogEntry>> {
20 self.conn().query_row(
21 "SELECT id FROM sessions WHERE id = ?1",
22 params![session_id],
23 |r| r.get::<_, String>(0),
24 ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
25
26 let lim = limit.unwrap_or(1000);
27 let mut stmt = self.conn().prepare(
28 "SELECT id, session_id, action, query, memory_ids, tokens_injected, created_at
29 FROM access_log
30 WHERE session_id = ?1
31 ORDER BY created_at ASC
32 LIMIT ?2",
33 )?;
34 let results = stmt.query_map(params![session_id, lim], |row| {
35 Ok(crate::types::AccessLogEntry {
36 id: row.get(0)?,
37 session_id: row.get(1)?,
38 action: row.get(2)?,
39 query: row.get(3)?,
40 memory_ids: row.get(4)?,
41 tokens_injected: row.get(5)?,
42 created_at: row.get(6)?,
43 })
44 })?.collect::<std::result::Result<Vec<_>, _>>()?;
45 Ok(results)
46 }
47
48 pub fn get_session_access_log_all(&self, limit: i32) -> Result<Vec<crate::types::AccessLogEntry>> {
50 let mut stmt = self.conn().prepare(
51 "SELECT id, session_id, action, query, memory_ids, tokens_injected, created_at
52 FROM access_log ORDER BY created_at DESC LIMIT ?1",
53 )?;
54 let results = stmt.query_map(params![limit], |row| {
55 Ok(crate::types::AccessLogEntry {
56 id: row.get(0)?,
57 session_id: row.get(1)?,
58 action: row.get(2)?,
59 query: row.get(3)?,
60 memory_ids: row.get(4)?,
61 tokens_injected: row.get(5)?,
62 created_at: row.get(6)?,
63 })
64 })?.collect::<std::result::Result<Vec<_>, _>>()?;
65 Ok(results)
66 }
67
68 pub fn delete_access_log_entry(&self, id: i64) -> Result<()> {
69 self.conn().execute("DELETE FROM access_log WHERE id = ?1", params![id])?;
70 Ok(())
71 }
72
73 pub fn clear_session_access_log(&self, session_id: &str) -> Result<()> {
77 self.conn().query_row(
79 "SELECT id FROM sessions WHERE id = ?1",
80 params![session_id],
81 |r| r.get::<_, String>(0),
82 ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
83
84 self.conn().execute(
85 "DELETE FROM access_log WHERE session_id = ?1",
86 params![session_id],
87 )?;
88 Ok(())
89 }
90
91 pub fn record_injection(&self, memory_ids: &[i64], tokens_per_memory: i32) -> Result<()> {
92 let tx = self.conn().unchecked_transaction()?;
93 for id in memory_ids {
94 tx.execute(
95 "INSERT INTO metrics (memory_id, injections, tokens_injected, last_injected_at)
96 VALUES (?1, 1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
97 ON CONFLICT(memory_id) DO UPDATE SET
98 injections = injections + 1,
99 tokens_injected = tokens_injected + ?2,
100 last_injected_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
101 params![id, tokens_per_memory],
102 )?;
103 }
104 tx.commit()?;
105 Ok(())
106 }
107
108 pub fn record_hit(&self, memory_id: i64) -> Result<()> {
109 self.conn().execute(
110 "INSERT INTO metrics (memory_id, hits, last_hit_at)
111 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
112 ON CONFLICT(memory_id) DO UPDATE SET
113 hits = hits + 1,
114 last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
115 params![memory_id],
116 )?;
117 Ok(())
118 }
119
120 pub fn record_hit_batch(&self, memory_ids: &[i64]) -> Result<()> {
121 let tx = self.conn().unchecked_transaction()?;
122 for id in memory_ids {
123 tx.execute(
124 "INSERT INTO metrics (memory_id, hits, last_hit_at)
125 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
126 ON CONFLICT(memory_id) DO UPDATE SET
127 hits = hits + 1,
128 last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
129 params![id],
130 )?;
131 }
132 tx.commit()?;
133 Ok(())
134 }
135
136 pub fn cumulative_stats(&self) -> Result<crate::types::TokenStats> {
138 let (injections, hits) = self.conn().query_row(
139 "SELECT COALESCE(SUM(injections), 0), COALESCE(SUM(hits), 0) FROM metrics",
140 [],
141 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
142 )?;
143 let unique = self.conn().query_row(
144 "SELECT COUNT(*) FROM metrics WHERE injections > 0",
145 [],
146 |row| row.get::<_, i64>(0),
147 )?;
148 Ok(crate::types::TokenStats {
149 injections,
150 hits,
151 unique_memories_injected: unique,
152 })
153 }
154
155 pub fn session_token_stats(&self, session_id: &str) -> Result<crate::types::TokenStats> {
157 self.conn().query_row(
158 "SELECT id FROM sessions WHERE id = ?1",
159 params![session_id],
160 |row| row.get::<_, String>(0),
161 ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
162
163 let injection_rows: Vec<String> = {
166 let mut stmt = self.conn().prepare(
167 "SELECT memory_ids FROM access_log
168 WHERE action = 'context' AND session_id = ?1"
169 )?;
170 stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?
171 .filter_map(|r| r.ok())
172 .collect()
173 };
174 let mut injections: i64 = 0;
175 let mut injected_set = std::collections::HashSet::new();
176 for ids_json in &injection_rows {
177 if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
178 injections += ids.len() as i64;
179 injected_set.extend(ids);
180 }
181 }
182
183 let hit_rows: Vec<String> = {
185 let mut stmt = self.conn().prepare(
186 "SELECT memory_ids FROM access_log
187 WHERE action IN ('search', 'detail', 'context') AND session_id = ?1"
188 )?;
189 stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?
190 .filter_map(|r| r.ok())
191 .collect()
192 };
193 let mut hits: i64 = 0;
194 for ids_json in &hit_rows {
195 if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
196 hits += ids.len() as i64;
197 }
198 }
199
200 Ok(crate::types::TokenStats {
201 injections,
202 hits,
203 unique_memories_injected: injected_set.len() as i64,
204 })
205 }
206
207 pub fn access_log_stats(&self) -> Result<Vec<(String, i64)>> {
208 let mut stmt = self.conn().prepare(
209 "SELECT action, COUNT(*) FROM access_log GROUP BY action ORDER BY COUNT(*) DESC",
210 )?;
211 let results = stmt
212 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?
213 .collect::<std::result::Result<Vec<_>, _>>()?;
214 Ok(results)
215 }
216
217 pub fn access_log_total(&self) -> Result<i64> {
218 Ok(self.conn().query_row(
219 "SELECT COUNT(*) FROM access_log",
220 [],
221 |row| row.get(0),
222 )?)
223 }
224
225 pub fn dedup_total(&self) -> Result<i64> {
226 Ok(self.conn().query_row(
227 "SELECT COALESCE(SUM(duplicate_count), 0) FROM memories WHERE deleted_at IS NULL",
228 [],
229 |row| row.get(0),
230 )?)
231 }
232
233 pub fn revision_total(&self) -> Result<i64> {
234 Ok(self.conn().query_row(
235 "SELECT COALESCE(SUM(revision_count), 0) FROM memories WHERE deleted_at IS NULL",
236 [],
237 |row| row.get(0),
238 )?)
239 }
240
241 pub fn low_roi_count(&self) -> Result<i64> {
242 Ok(self.conn().query_row(
243 "SELECT COUNT(*) FROM metrics WHERE injections > 10 AND CAST(hits AS REAL) / injections < 0.1",
244 [],
245 |row| row.get(0),
246 )?)
247 }
248
249 pub fn top_searches(&self, limit: i32) -> Result<Vec<(String, i64)>> {
250 let mut stmt = self.conn().prepare(
251 "SELECT query, COUNT(*) as cnt FROM access_log
252 WHERE action = 'search' AND query IS NOT NULL
253 GROUP BY lower(query)
254 ORDER BY cnt DESC
255 LIMIT ?1",
256 )?;
257 let results = stmt
258 .query_map(rusqlite::params![limit], |row| {
259 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
260 })?
261 .collect::<std::result::Result<Vec<_>, _>>()?;
262 Ok(results)
263 }
264
265 pub fn get_metrics(&self) -> Result<Vec<MemoryMetric>> {
266 let mut stmt = self.conn().prepare(
267 "SELECT m.id, m.key, m.scope,
268 COALESCE(mt.injections, 0),
269 COALESCE(mt.hits, 0),
270 COALESCE(mt.tokens_injected, 0),
271 CASE WHEN COALESCE(mt.injections, 0) > 0
272 THEN CAST(COALESCE(mt.hits, 0) AS REAL) / mt.injections
273 ELSE 0.0 END
274 FROM memories m
275 LEFT JOIN metrics mt ON mt.memory_id = m.id
276 WHERE m.deleted_at IS NULL
277 ORDER BY COALESCE(mt.injections, 0) DESC
278 LIMIT 100",
279 )?;
280 let results = stmt
281 .query_map([], |row| {
282 Ok(MemoryMetric {
283 id: row.get(0)?,
284 key: row.get(1)?,
285 scope: row.get(2)?,
286 injections: row.get(3)?,
287 hits: row.get(4)?,
288 tokens_injected: row.get(5)?,
289 hit_rate: row.get(6)?,
290 })
291 })?
292 .collect::<std::result::Result<Vec<_>, _>>()?;
293 Ok(results)
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use crate::store::Store;
300 use crate::types::SaveParams;
301
302 fn make_memory(store: &Store, key: &str) -> i64 {
303 store
304 .save(SaveParams {
305 key: key.to_string(),
306 value: "test value".to_string(),
307 ..Default::default()
308 })
309 .unwrap()
310 .id()
311 }
312
313 #[test]
314 fn test_log_access_no_query() {
315 let store = Store::open_in_memory().unwrap();
316 store.log_access(None, "search", None, &[], 0).unwrap();
317 }
318
319 #[test]
320 fn test_log_access_with_query_and_ids() {
321 let store = Store::open_in_memory().unwrap();
322 let id = make_memory(&store, "test/key");
323 store.log_access(None, "search", Some("test query"), &[id], 0).unwrap();
324 }
325
326 #[test]
327 fn test_record_injection_increments() {
328 let store = Store::open_in_memory().unwrap();
329 let id = make_memory(&store, "test/key");
330
331 store.record_injection(&[id], 0).unwrap();
332 store.record_injection(&[id], 0).unwrap();
333
334 let metrics = store.get_metrics().unwrap();
335 let m = metrics.iter().find(|m| m.id == id).unwrap();
336 assert_eq!(m.injections, 2);
337 }
338
339 #[test]
340 fn test_record_hit_increments() {
341 let store = Store::open_in_memory().unwrap();
342 let id = make_memory(&store, "test/key");
343
344 store.record_hit(id).unwrap();
345 store.record_hit(id).unwrap();
346 store.record_hit(id).unwrap();
347
348 let metrics = store.get_metrics().unwrap();
349 let m = metrics.iter().find(|m| m.id == id).unwrap();
350 assert_eq!(m.hits, 3);
351 }
352
353 #[test]
354 fn test_hit_rate_calculation() {
355 let store = Store::open_in_memory().unwrap();
356 let id = make_memory(&store, "test/key");
357
358 store.record_injection(&[id], 0).unwrap();
359 store.record_injection(&[id], 0).unwrap();
360 store.record_hit(id).unwrap();
361
362 let metrics = store.get_metrics().unwrap();
363 let m = metrics.iter().find(|m| m.id == id).unwrap();
364 assert_eq!(m.injections, 2);
365 assert_eq!(m.hits, 1);
366 assert!((m.hit_rate - 0.5).abs() < f64::EPSILON);
367 }
368
369 #[test]
370 fn test_hit_rate_zero_when_no_injections() {
371 let store = Store::open_in_memory().unwrap();
372 let id = make_memory(&store, "test/key");
373
374 let metrics = store.get_metrics().unwrap();
375 let m = metrics.iter().find(|m| m.id == id).unwrap();
376 assert_eq!(m.hit_rate, 0.0);
377 }
378
379 #[test]
380 fn test_get_metrics_excludes_deleted() {
381 let store = Store::open_in_memory().unwrap();
382 let id = make_memory(&store, "test/key");
383 store.delete("test/key", None, false).unwrap();
384
385 let metrics = store.get_metrics().unwrap();
386 assert!(metrics.iter().find(|m| m.id == id).is_none());
387 }
388
389 #[test]
390 fn test_get_metrics_ordered_by_injections_desc() {
391 let store = Store::open_in_memory().unwrap();
392 let id1 = make_memory(&store, "key/one");
393 let id2 = make_memory(&store, "key/two");
394
395 store.record_injection(&[id1], 0).unwrap();
396 store.record_injection(&[id1], 0).unwrap();
397 store.record_injection(&[id1], 0).unwrap();
398 store.record_injection(&[id2], 0).unwrap();
399
400 let metrics = store.get_metrics().unwrap();
401 let pos1 = metrics.iter().position(|m| m.id == id1).unwrap();
402 let pos2 = metrics.iter().position(|m| m.id == id2).unwrap();
403 assert!(pos1 < pos2, "id1 (3 injections) should come before id2 (1 injection)");
404 }
405
406 #[test]
407 fn test_record_injection_accumulates_tokens() {
408 let store = Store::open_in_memory().unwrap();
409 let id = make_memory(&store, "key/one");
410
411 store.record_injection(&[id], 50).unwrap();
412 store.record_injection(&[id], 50).unwrap();
413
414 let metrics = store.get_metrics().unwrap();
415 let m = metrics.iter().find(|m| m.id == id).unwrap();
416 assert_eq!(m.injections, 2);
417 assert_eq!(m.tokens_injected, 100);
418 }
419
420 #[test]
421 fn test_record_injection_multiple_ids() {
422 let store = Store::open_in_memory().unwrap();
423 let id1 = make_memory(&store, "key/one");
424 let id2 = make_memory(&store, "key/two");
425
426 store.record_injection(&[id1, id2], 0).unwrap();
427
428 let metrics = store.get_metrics().unwrap();
429 let m1 = metrics.iter().find(|m| m.id == id1).unwrap();
430 let m2 = metrics.iter().find(|m| m.id == id2).unwrap();
431 assert_eq!(m1.injections, 1);
432 assert_eq!(m2.injections, 1);
433 }
434
435 #[test]
436 fn test_migration_006_columns_exist() {
437 let store = Store::open_in_memory().unwrap();
438 store.conn().execute(
440 "INSERT INTO access_log (action, tokens_injected) VALUES ('context', 42)",
441 [],
442 ).unwrap();
443 let tok: i32 = store.conn().query_row(
444 "SELECT tokens_injected FROM access_log WHERE action = 'context'",
445 [],
446 |r| r.get(0),
447 ).unwrap();
448 assert_eq!(tok, 42);
449
450 store.conn().execute(
452 "UPDATE sessions SET tokens_used_input = 100, tokens_used_output = 50 WHERE 1=0",
453 [],
454 ).unwrap();
455 }
456
457 #[test]
458 fn test_get_session_access_log_returns_entries() {
459 let store = Store::open_in_memory().unwrap();
460 let session = store.session_start("test-proj", None).unwrap();
461 store.log_access(Some(&session.id), "context", None, &[], 320).unwrap();
462 store.log_access(Some(&session.id), "search", Some("rust errors"), &[], 0).unwrap();
463 let entries = store.get_session_access_log(&session.id, None).unwrap();
464 assert_eq!(entries.len(), 2);
465 assert_eq!(entries[0].action, "context");
466 assert_eq!(entries[0].tokens_injected, 320);
467 assert_eq!(entries[1].action, "search");
468 }
469
470 #[test]
471 fn test_get_session_access_log_isolation() {
472 let store = Store::open_in_memory().unwrap();
475 let session_a = store.session_start("project-a", None).unwrap();
476 let session_b = store.session_start("project-b", None).unwrap();
477 store.log_access(Some(&session_a.id), "search", Some("aaa"), &[], 0).unwrap();
478 store.log_access(Some(&session_b.id), "search", Some("bbb"), &[], 0).unwrap();
479 store.log_access(None, "search", Some("orphan"), &[], 0).unwrap();
481 let entries_a = store.get_session_access_log(&session_a.id, None).unwrap();
482 let entries_b = store.get_session_access_log(&session_b.id, None).unwrap();
483 assert_eq!(entries_a.len(), 1);
484 assert_eq!(entries_a[0].query.as_deref(), Some("aaa"));
485 assert_eq!(entries_b.len(), 1);
486 assert_eq!(entries_b[0].query.as_deref(), Some("bbb"));
487 }
488
489 #[test]
490 fn test_get_session_access_log_null_session_isolation() {
491 let store = Store::open_in_memory().unwrap();
493 let session = store.session_start("test-proj", None).unwrap();
494 store.log_access(Some(&session.id), "context", None, &[], 100).unwrap();
495 store.log_access(None, "search", Some("orphan"), &[], 0).unwrap();
497 let entries = store.get_session_access_log(&session.id, None).unwrap();
498 assert_eq!(entries.len(), 1, "NULL session_id entries must not appear in session log");
499 assert_eq!(entries[0].action, "context");
500 }
501
502 #[test]
503 fn test_session_token_stats_null_session_isolation() {
504 let store = Store::open_in_memory().unwrap();
506 let mem = make_memory(&store, "key/one");
507 let session = store.session_start("test-proj", None).unwrap();
508 store.log_access(Some(&session.id), "context", None, &[mem], 100).unwrap();
509 store.log_access(None, "context", None, &[mem], 200).unwrap();
511 let stats = store.session_token_stats(&session.id).unwrap();
512 assert_eq!(stats.injections, 1, "only 1 injection attributed to session");
513 assert_eq!(stats.unique_memories_injected, 1);
514 }
515
516 #[test]
517 fn test_delete_access_log_entry() {
518 let store = Store::open_in_memory().unwrap();
519 store.log_access(None, "context", None, &[], 100).unwrap();
520 let entries = store.get_session_access_log_all(10).unwrap();
521 assert_eq!(entries.len(), 1);
522 let id = entries[0].id;
523 store.delete_access_log_entry(id).unwrap();
524 let entries = store.get_session_access_log_all(10).unwrap();
525 assert_eq!(entries.len(), 0);
526 }
527
528 #[test]
529 fn test_clear_session_access_log() {
530 let store = Store::open_in_memory().unwrap();
531 let session = store.session_start("proj", None).unwrap();
532 store.log_access(Some(&session.id), "context", None, &[], 100).unwrap();
533 store.log_access(Some(&session.id), "search", Some("q"), &[], 0).unwrap();
534 store.clear_session_access_log(&session.id).unwrap();
535 let entries = store.get_session_access_log(&session.id, None).unwrap();
536 assert_eq!(entries.len(), 0);
537 }
538}