1use anyhow::Result;
20use sqlx::sqlite::SqliteConnectOptions;
21use sqlx::SqlitePool;
22use std::str::FromStr;
23use uuid::Uuid;
24
25#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
30pub struct CompactionRow {
31 pub session_id: String,
32 pub compacted_at: i64, pub head_turn_count: i64,
34 pub tail_start_index: i64,
35 pub summary: String,
36 pub model_used: String,
37 pub input_tokens: i64,
38 pub output_tokens: i64,
39}
40
41pub struct CompactionStore {
42 pool: SqlitePool,
43}
44
45impl CompactionStore {
46 pub async fn open(db_path: &str) -> Result<Self> {
49 let opts = SqliteConnectOptions::from_str(db_path)?
50 .create_if_missing(true)
51 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
52 let pool = SqlitePool::connect_with(opts).await?;
53 sqlx::query("PRAGMA foreign_keys=ON").execute(&pool).await?;
54 let store = Self { pool };
55 store.migrate().await?;
56 Ok(store)
57 }
58
59 pub fn with_pool(pool: SqlitePool) -> Self {
62 Self { pool }
63 }
64
65 pub async fn migrate(&self) -> Result<()> {
66 sqlx::query(
67 "CREATE TABLE IF NOT EXISTS compactions_v1 (
68 session_id TEXT NOT NULL,
69 compacted_at INTEGER NOT NULL,
70 head_turn_count INTEGER NOT NULL,
71 tail_start_index INTEGER NOT NULL,
72 summary TEXT NOT NULL,
73 model_used TEXT NOT NULL,
74 input_tokens INTEGER NOT NULL,
75 output_tokens INTEGER NOT NULL,
76 PRIMARY KEY (session_id, compacted_at)
77 )",
78 )
79 .execute(&self.pool)
80 .await?;
81 sqlx::query(
82 "CREATE INDEX IF NOT EXISTS idx_compactions_v1_session
83 ON compactions_v1(session_id, compacted_at DESC)",
84 )
85 .execute(&self.pool)
86 .await?;
87 sqlx::query(
88 "CREATE TABLE IF NOT EXISTS compaction_locks_v1 (
89 session_id TEXT PRIMARY KEY,
90 locked_at INTEGER NOT NULL,
91 holder TEXT NOT NULL
92 )",
93 )
94 .execute(&self.pool)
95 .await?;
96 Ok(())
97 }
98
99 pub async fn insert(&self, row: &CompactionRow) -> Result<()> {
101 sqlx::query(
102 "INSERT INTO compactions_v1 (
103 session_id, compacted_at, head_turn_count, tail_start_index,
104 summary, model_used, input_tokens, output_tokens
105 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
106 )
107 .bind(&row.session_id)
108 .bind(row.compacted_at)
109 .bind(row.head_turn_count)
110 .bind(row.tail_start_index)
111 .bind(&row.summary)
112 .bind(&row.model_used)
113 .bind(row.input_tokens)
114 .bind(row.output_tokens)
115 .execute(&self.pool)
116 .await?;
117 Ok(())
118 }
119
120 pub async fn latest(&self, session_id: Uuid) -> Result<Option<CompactionRow>> {
122 let row: Option<CompactionRow> = sqlx::query_as(
123 "SELECT session_id, compacted_at, head_turn_count, tail_start_index,
124 summary, model_used, input_tokens, output_tokens
125 FROM compactions_v1
126 WHERE session_id = ?
127 ORDER BY compacted_at DESC
128 LIMIT 1",
129 )
130 .bind(session_id.to_string())
131 .fetch_optional(&self.pool)
132 .await?;
133 Ok(row)
134 }
135
136 pub async fn list_for_session(
138 &self,
139 session_id: Uuid,
140 limit: u32,
141 ) -> Result<Vec<CompactionRow>> {
142 let rows: Vec<CompactionRow> = sqlx::query_as(
143 "SELECT session_id, compacted_at, head_turn_count, tail_start_index,
144 summary, model_used, input_tokens, output_tokens
145 FROM compactions_v1
146 WHERE session_id = ?
147 ORDER BY compacted_at DESC
148 LIMIT ?",
149 )
150 .bind(session_id.to_string())
151 .bind(limit as i64)
152 .fetch_all(&self.pool)
153 .await?;
154 Ok(rows)
155 }
156
157 pub async fn try_acquire_lock(
166 &self,
167 session_id: Uuid,
168 holder: &str,
169 ttl_seconds: u32,
170 ) -> Result<bool> {
171 let now_ms = chrono::Utc::now().timestamp_millis();
175 let cutoff_ms = now_ms - (ttl_seconds as i64 * 1000);
176 sqlx::query(
177 "DELETE FROM compaction_locks_v1
178 WHERE session_id = ? AND locked_at < ?",
179 )
180 .bind(session_id.to_string())
181 .bind(cutoff_ms)
182 .execute(&self.pool)
183 .await?;
184 let result = sqlx::query(
188 "INSERT OR IGNORE INTO compaction_locks_v1 (session_id, locked_at, holder)
189 VALUES (?, ?, ?)",
190 )
191 .bind(session_id.to_string())
192 .bind(now_ms)
193 .bind(holder)
194 .execute(&self.pool)
195 .await?;
196 Ok(result.rows_affected() == 1)
197 }
198
199 pub async fn release_lock(&self, session_id: Uuid) -> Result<()> {
201 sqlx::query("DELETE FROM compaction_locks_v1 WHERE session_id = ?")
202 .bind(session_id.to_string())
203 .execute(&self.pool)
204 .await?;
205 Ok(())
206 }
207
208 pub async fn cleanup_stale_locks(&self, ttl_seconds: u32) -> Result<u64> {
212 let now_ms = chrono::Utc::now().timestamp_millis();
213 let cutoff_ms = now_ms - (ttl_seconds as i64 * 1000);
214 let result = sqlx::query("DELETE FROM compaction_locks_v1 WHERE locked_at < ?")
215 .bind(cutoff_ms)
216 .execute(&self.pool)
217 .await?;
218 Ok(result.rows_affected())
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 async fn open_mem() -> CompactionStore {
227 CompactionStore::open(":memory:").await.unwrap()
228 }
229
230 fn row(session: Uuid, ts: i64) -> CompactionRow {
231 CompactionRow {
232 session_id: session.to_string(),
233 compacted_at: ts,
234 head_turn_count: 5,
235 tail_start_index: 5,
236 summary: "Compacted: discussed weather.".into(),
237 model_used: "claude-sonnet-4-5".into(),
238 input_tokens: 10_000,
239 output_tokens: 800,
240 }
241 }
242
243 #[tokio::test]
244 async fn migrate_is_idempotent() {
245 let s = open_mem().await;
246 s.migrate().await.unwrap();
247 s.migrate().await.unwrap();
248 }
249
250 #[tokio::test]
251 async fn insert_and_latest_roundtrip() {
252 let s = open_mem().await;
253 let session = Uuid::new_v4();
254 s.insert(&row(session, 1)).await.unwrap();
255 s.insert(&row(session, 2)).await.unwrap();
256 let latest = s.latest(session).await.unwrap().unwrap();
257 assert_eq!(latest.compacted_at, 2);
258 }
259
260 #[tokio::test]
261 async fn list_for_session_orders_newest_first() {
262 let s = open_mem().await;
263 let session = Uuid::new_v4();
264 for i in [3, 1, 2] {
265 s.insert(&row(session, i)).await.unwrap();
266 }
267 let rows = s.list_for_session(session, 10).await.unwrap();
268 let timestamps: Vec<i64> = rows.iter().map(|r| r.compacted_at).collect();
269 assert_eq!(timestamps, vec![3, 2, 1]);
270 }
271
272 #[tokio::test]
273 async fn list_respects_limit() {
274 let s = open_mem().await;
275 let session = Uuid::new_v4();
276 for i in 0..5 {
277 s.insert(&row(session, i)).await.unwrap();
278 }
279 let rows = s.list_for_session(session, 2).await.unwrap();
280 assert_eq!(rows.len(), 2);
281 }
282
283 #[tokio::test]
284 async fn lock_acquire_and_double_acquire_blocks() {
285 let s = open_mem().await;
286 let session = Uuid::new_v4();
287 assert!(s.try_acquire_lock(session, "p1", 60).await.unwrap());
288 assert!(!s.try_acquire_lock(session, "p2", 60).await.unwrap());
289 }
290
291 #[tokio::test]
292 async fn release_unlocks() {
293 let s = open_mem().await;
294 let session = Uuid::new_v4();
295 assert!(s.try_acquire_lock(session, "p1", 60).await.unwrap());
296 s.release_lock(session).await.unwrap();
297 assert!(s.try_acquire_lock(session, "p1", 60).await.unwrap());
298 }
299
300 #[tokio::test]
301 async fn release_is_idempotent() {
302 let s = open_mem().await;
303 let session = Uuid::new_v4();
304 s.release_lock(session).await.unwrap();
305 s.release_lock(session).await.unwrap();
306 }
307
308 #[tokio::test]
309 async fn stale_lock_evicted_on_acquire() {
310 let s = open_mem().await;
311 let session = Uuid::new_v4();
312 sqlx::query(
314 "INSERT INTO compaction_locks_v1 (session_id, locked_at, holder)
315 VALUES (?, 0, 'crashed-process')",
316 )
317 .bind(session.to_string())
318 .execute(&s.pool)
319 .await
320 .unwrap();
321 assert!(s.try_acquire_lock(session, "p2", 1).await.unwrap());
323 }
324
325 #[tokio::test]
326 async fn cleanup_stale_locks_returns_count() {
327 let s = open_mem().await;
328 sqlx::query(
329 "INSERT INTO compaction_locks_v1 (session_id, locked_at, holder)
330 VALUES (?, 0, 'crashed1')",
331 )
332 .bind(Uuid::new_v4().to_string())
333 .execute(&s.pool)
334 .await
335 .unwrap();
336 sqlx::query(
337 "INSERT INTO compaction_locks_v1 (session_id, locked_at, holder)
338 VALUES (?, 0, 'crashed2')",
339 )
340 .bind(Uuid::new_v4().to_string())
341 .execute(&s.pool)
342 .await
343 .unwrap();
344 let fresh = Uuid::new_v4();
346 s.try_acquire_lock(fresh, "live", 60).await.unwrap();
347 let removed = s.cleanup_stale_locks(60).await.unwrap();
348 assert_eq!(removed, 2);
349 assert!(!s.try_acquire_lock(fresh, "other", 60).await.unwrap());
351 }
352}