1use super::models::{RunRecord, SessionRecord};
9use super::schema;
10use crate::error::StoreError;
11use rusqlite::{named_params, params, Connection, OptionalExtension};
12use std::path::Path;
13use std::time::Duration;
14
15pub struct Db {
16 pub conn: Connection,
17}
18
19#[derive(Debug, Clone, Default)]
21pub struct StatsAgg {
22 pub runs_captured: i64,
23 pub optimized: i64,
24 pub unchanged: i64,
25 pub small_delta: i64,
26 pub large_delta: i64,
27 pub passthrough: i64,
28 pub raw_tokens: i64,
29 pub emitted_tokens: i64,
30 pub saved_tokens: i64,
31 pub full_output_requested: i64,
32 pub internal_error: i64,
33 pub avg_overhead_ms: f64,
34}
35
36impl Db {
37 pub fn open_read_only(path: &Path) -> Result<Db, StoreError> {
41 let conn = Connection::open_with_flags(
42 path,
43 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
44 )?;
45 conn.busy_timeout(Duration::from_millis(5000))?;
46 Ok(Db { conn })
47 }
48
49 pub fn open(path: &Path) -> Result<Db, StoreError> {
50 if let Some(parent) = path.parent() {
51 std::fs::create_dir_all(parent)?;
52 }
53 let conn = Connection::open(path)?;
54 conn.execute_batch(
57 "PRAGMA journal_mode=WAL;\
58 PRAGMA synchronous=NORMAL;\
59 PRAGMA foreign_keys=ON;",
60 )?;
61 conn.busy_timeout(Duration::from_millis(5000))?;
62 let db = Db { conn };
63 db.migrate()?;
64 Ok(db)
65 }
66
67 fn migrate(&self) -> Result<(), StoreError> {
68 let version: i64 = self
69 .conn
70 .query_row("PRAGMA user_version", [], |row| row.get(0))?;
71 if version < schema::SCHEMA_VERSION {
72 self.conn.execute_batch(schema::SCHEMA_V1)?;
73 self.conn
74 .execute_batch(&format!("PRAGMA user_version={};", schema::SCHEMA_VERSION))?;
75 }
76 Ok(())
77 }
78
79 pub fn insert_run(&self, r: &RunRecord) -> Result<(), StoreError> {
80 self.conn.execute(
81 "INSERT INTO runs (
82 id, session_id, created_at, repo_root, cwd, shim_name, argv_json,
83 command_original, command_family, command_key, classification,
84 exit_code, duration_ms, overhead_ms, stdout_path, stderr_path,
85 normalized_path, raw_stdout_bytes, raw_stderr_bytes, raw_total_bytes,
86 emitted_bytes, estimated_raw_tokens, estimated_emitted_tokens,
87 estimated_saved_tokens, normalized_hash, stdout_hash, stderr_hash,
88 git_head, git_worktree_hash, comparison_base_run_id, comparison_result,
89 summary, full_output_requested, internal_error
90 ) VALUES (
91 :id, :session_id, :created_at, :repo_root, :cwd, :shim_name, :argv_json,
92 :command_original, :command_family, :command_key, :classification,
93 :exit_code, :duration_ms, :overhead_ms, :stdout_path, :stderr_path,
94 :normalized_path, :raw_stdout_bytes, :raw_stderr_bytes, :raw_total_bytes,
95 :emitted_bytes, :estimated_raw_tokens, :estimated_emitted_tokens,
96 :estimated_saved_tokens, :normalized_hash, :stdout_hash, :stderr_hash,
97 :git_head, :git_worktree_hash, :comparison_base_run_id, :comparison_result,
98 :summary, :full_output_requested, :internal_error
99 )",
100 named_params! {
101 ":id": r.id,
102 ":session_id": r.session_id,
103 ":created_at": r.created_at,
104 ":repo_root": r.repo_root,
105 ":cwd": r.cwd,
106 ":shim_name": r.shim_name,
107 ":argv_json": r.argv_json,
108 ":command_original": r.command_original,
109 ":command_family": r.command_family,
110 ":command_key": r.command_key,
111 ":classification": r.classification,
112 ":exit_code": r.exit_code,
113 ":duration_ms": r.duration_ms,
114 ":overhead_ms": r.overhead_ms,
115 ":stdout_path": r.stdout_path,
116 ":stderr_path": r.stderr_path,
117 ":normalized_path": r.normalized_path,
118 ":raw_stdout_bytes": r.raw_stdout_bytes,
119 ":raw_stderr_bytes": r.raw_stderr_bytes,
120 ":raw_total_bytes": r.raw_total_bytes,
121 ":emitted_bytes": r.emitted_bytes,
122 ":estimated_raw_tokens": r.estimated_raw_tokens,
123 ":estimated_emitted_tokens": r.estimated_emitted_tokens,
124 ":estimated_saved_tokens": r.estimated_saved_tokens,
125 ":normalized_hash": r.normalized_hash,
126 ":stdout_hash": r.stdout_hash,
127 ":stderr_hash": r.stderr_hash,
128 ":git_head": r.git_head,
129 ":git_worktree_hash": r.git_worktree_hash,
130 ":comparison_base_run_id": r.comparison_base_run_id,
131 ":comparison_result": r.comparison_result,
132 ":summary": r.summary,
133 ":full_output_requested": r.full_output_requested,
134 ":internal_error": r.internal_error,
135 },
136 )?;
137 Ok(())
138 }
139
140 pub fn get_run(&self, target: &str) -> Result<Option<RunRecord>, StoreError> {
142 if let Some(run) = self
143 .conn
144 .query_row("SELECT * FROM runs WHERE id = ?1", [target], |row| {
145 RunRecord::from_row(row)
146 })
147 .optional()?
148 {
149 return Ok(Some(run));
150 }
151 let pattern = format!("{target}%");
152 let run = self
153 .conn
154 .query_row(
155 "SELECT * FROM runs WHERE id LIKE ?1 ORDER BY created_at DESC, rowid DESC LIMIT 1",
156 [pattern],
157 RunRecord::from_row,
158 )
159 .optional()?;
160 Ok(run)
161 }
162
163 pub fn count_prefix(&self, prefix: &str) -> Result<i64, StoreError> {
165 let pattern = format!("{prefix}%");
166 let count: i64 = self.conn.query_row(
167 "SELECT COUNT(*) FROM runs WHERE id LIKE ?1",
168 [pattern],
169 |r| r.get(0),
170 )?;
171 Ok(count)
172 }
173
174 pub fn latest_run(&self, repo_root: &str) -> Result<Option<RunRecord>, StoreError> {
175 let run = self
176 .conn
177 .query_row(
178 "SELECT * FROM runs WHERE repo_root = ?1 ORDER BY created_at DESC, rowid DESC LIMIT 1",
179 [repo_root],
180 RunRecord::from_row,
181 )
182 .optional()?;
183 Ok(run)
184 }
185
186 pub fn find_comparable_prior(
190 &self,
191 repo_root: &str,
192 cwd: &str,
193 command_family: &str,
194 command_key: &str,
195 before_created_at: &str,
196 ) -> Result<Option<RunRecord>, StoreError> {
197 let run = self
198 .conn
199 .query_row(
200 "SELECT * FROM runs
201 WHERE repo_root = :repo_root AND cwd = :cwd
202 AND command_family = :family AND command_key = :key
203 AND created_at < :before
204 AND classification != 'internal_error'
205 ORDER BY created_at DESC, rowid DESC LIMIT 1",
206 named_params! {
207 ":repo_root": repo_root,
208 ":cwd": cwd,
209 ":family": command_family,
210 ":key": command_key,
211 ":before": before_created_at,
212 },
213 RunRecord::from_row,
214 )
215 .optional()?;
216 Ok(run)
217 }
218
219 pub fn upsert_session_start(&self, s: &SessionRecord) -> Result<(), StoreError> {
220 self.conn.execute(
221 "INSERT INTO sessions (id, created_at, ended_at, repo_root, agent_command,
222 raw_tokens_total, emitted_tokens_total, saved_tokens_total)
223 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
224 ON CONFLICT(id) DO NOTHING",
225 params![
226 s.id,
227 s.created_at,
228 s.ended_at,
229 s.repo_root,
230 s.agent_command,
231 s.raw_tokens_total,
232 s.emitted_tokens_total,
233 s.saved_tokens_total,
234 ],
235 )?;
236 Ok(())
237 }
238
239 pub fn accumulate_session_tokens(
240 &self,
241 session_id: &str,
242 raw: i64,
243 emitted: i64,
244 saved: i64,
245 ) -> Result<(), StoreError> {
246 self.conn.execute(
247 "UPDATE sessions SET
248 raw_tokens_total = raw_tokens_total + ?2,
249 emitted_tokens_total = emitted_tokens_total + ?3,
250 saved_tokens_total = saved_tokens_total + ?4
251 WHERE id = ?1",
252 params![session_id, raw, emitted, saved],
253 )?;
254 Ok(())
255 }
256
257 pub fn mark_full_output_requested(&self, run_id: &str) -> Result<(), StoreError> {
258 self.conn.execute(
259 "UPDATE runs SET full_output_requested = 1
260 WHERE id = ?1 AND full_output_requested = 0",
261 [run_id],
262 )?;
263 Ok(())
264 }
265
266 pub fn runs_before(&self, repo_root: &str, cutoff: &str) -> Result<Vec<RunRecord>, StoreError> {
268 let mut stmt = self.conn.prepare(
269 "SELECT * FROM runs WHERE repo_root = ?1 AND created_at < ?2 ORDER BY created_at",
270 )?;
271 let rows = stmt.query_map(params![repo_root, cutoff], RunRecord::from_row)?;
272 let mut out = Vec::new();
273 for row in rows {
274 out.push(row?);
275 }
276 Ok(out)
277 }
278
279 pub fn delete_runs_before(&self, repo_root: &str, cutoff: &str) -> Result<usize, StoreError> {
281 let n = self.conn.execute(
282 "DELETE FROM runs WHERE repo_root = ?1 AND created_at < ?2",
283 params![repo_root, cutoff],
284 )?;
285 Ok(n)
286 }
287
288 pub fn aggregate_stats(&self, repo_root: Option<&str>) -> Result<StatsAgg, StoreError> {
290 let agg = self.conn.query_row(
291 "SELECT
292 COUNT(*),
293 COALESCE(SUM(CASE WHEN classification IN
294 ('first_seen','unchanged','small_delta','large_delta') THEN 1 ELSE 0 END), 0),
295 COALESCE(SUM(CASE WHEN classification='unchanged' THEN 1 ELSE 0 END), 0),
296 COALESCE(SUM(CASE WHEN classification='small_delta' THEN 1 ELSE 0 END), 0),
297 COALESCE(SUM(CASE WHEN classification='large_delta' THEN 1 ELSE 0 END), 0),
298 COALESCE(SUM(CASE WHEN classification='passthrough' THEN 1 ELSE 0 END), 0),
299 COALESCE(SUM(estimated_raw_tokens), 0),
300 COALESCE(SUM(estimated_emitted_tokens), 0),
301 COALESCE(SUM(estimated_saved_tokens), 0),
302 COALESCE(SUM(full_output_requested), 0),
303 COALESCE(SUM(CASE WHEN classification='internal_error' THEN 1 ELSE 0 END), 0),
304 COALESCE(AVG(overhead_ms), 0.0)
305 FROM runs WHERE (?1 IS NULL OR repo_root = ?1)",
306 params![repo_root],
307 |row| {
308 Ok(StatsAgg {
309 runs_captured: row.get(0)?,
310 optimized: row.get(1)?,
311 unchanged: row.get(2)?,
312 small_delta: row.get(3)?,
313 large_delta: row.get(4)?,
314 passthrough: row.get(5)?,
315 raw_tokens: row.get(6)?,
316 emitted_tokens: row.get(7)?,
317 saved_tokens: row.get(8)?,
318 full_output_requested: row.get(9)?,
319 internal_error: row.get(10)?,
320 avg_overhead_ms: row.get(11)?,
321 })
322 },
323 )?;
324 Ok(agg)
325 }
326
327 pub fn top_savings(
330 &self,
331 repo_root: Option<&str>,
332 limit: Option<usize>,
333 ) -> Result<Vec<(String, i64)>, StoreError> {
334 let mut stmt = self.conn.prepare(
335 "SELECT command_original, SUM(estimated_saved_tokens) AS saved
336 FROM runs
337 WHERE (?1 IS NULL OR repo_root = ?1) AND estimated_saved_tokens > 0
338 GROUP BY command_key
339 ORDER BY saved DESC LIMIT ?2",
340 )?;
341 let limit = limit.map(|l| l as i64).unwrap_or(-1);
342 let rows = stmt.query_map(params![repo_root, limit], |row| {
343 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
344 })?;
345 let mut out = Vec::new();
346 for row in rows {
347 out.push(row?);
348 }
349 Ok(out)
350 }
351
352 pub fn repo_roots(&self) -> Result<Vec<String>, StoreError> {
354 let mut stmt = self.conn.prepare(
355 "SELECT repo_root FROM (
356 SELECT DISTINCT repo_root FROM runs
357 UNION
358 SELECT DISTINCT repo_root FROM sessions
359 )
360 ORDER BY repo_root",
361 )?;
362 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
363 let mut out = Vec::new();
364 for row in rows {
365 out.push(row?);
366 }
367 Ok(out)
368 }
369
370 pub fn session_count(&self, repo_root: &str) -> Result<i64, StoreError> {
371 let count = self.conn.query_row(
372 "SELECT COUNT(*) FROM sessions WHERE repo_root = ?1",
373 [repo_root],
374 |r| r.get(0),
375 )?;
376 Ok(count)
377 }
378
379 pub fn latest_activity(&self, repo_root: &str) -> Result<Option<String>, StoreError> {
380 let latest = self.conn.query_row(
381 "SELECT MAX(created_at) FROM (
382 SELECT created_at FROM runs WHERE repo_root = ?1
383 UNION ALL
384 SELECT created_at FROM sessions WHERE repo_root = ?1
385 )",
386 [repo_root],
387 |r| r.get(0),
388 )?;
389 Ok(latest)
390 }
391}