1use zeph_db::{ActiveDialect, DbPool, dialect::Dialect, sql};
17
18use crate::error::SessionError;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum SessionStatus {
25 Active,
27 Idle,
29 Archived,
31}
32
33impl SessionStatus {
34 #[must_use]
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Active => "active",
39 Self::Idle => "idle",
40 Self::Archived => "archived",
41 }
42 }
43}
44
45impl std::str::FromStr for SessionStatus {
46 type Err = SessionError;
47
48 fn from_str(s: &str) -> Result<Self, Self::Err> {
49 match s {
50 "active" => Ok(Self::Active),
51 "idle" => Ok(Self::Idle),
52 "archived" => Ok(Self::Archived),
53 other => Err(SessionError::NotFound(format!(
54 "unknown session status: {other}"
55 ))),
56 }
57 }
58}
59
60#[derive(Debug, Clone, serde::Serialize)]
62pub struct SessionMetadata {
63 pub session_id: String,
64 pub title: Option<String>,
65 pub created_at: String,
66 pub updated_at: String,
67 pub conversation_id: Option<i64>,
68 pub last_seq: u64,
69 pub event_count: u64,
70 pub forked_from: Option<String>,
71 pub forked_at_seq: Option<u64>,
72 pub status: SessionStatus,
73 pub last_condensed_seq: u64,
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct SessionFilter {
79 pub status: Option<SessionStatus>,
81 pub limit: usize,
83}
84
85pub struct SessionStore {
87 pool: DbPool,
88}
89
90impl SessionStore {
91 #[must_use]
94 pub fn new(pool: DbPool) -> Self {
95 Self { pool }
96 }
97
98 #[tracing::instrument(name = "session.store.create", skip_all, level = "debug")]
105 pub async fn create(&self, session_id: &str) -> Result<(), SessionError> {
106 let stmt = zeph_db::rewrite_placeholders(&format!(
107 "{} INTO acp_sessions (id, status) VALUES (?, 'active'){}",
108 <ActiveDialect as Dialect>::INSERT_IGNORE,
109 <ActiveDialect as Dialect>::CONFLICT_NOTHING,
110 ));
111 zeph_db::query(sqlx::AssertSqlSafe(stmt))
112 .bind(session_id)
113 .execute(&self.pool)
114 .await?;
115 Ok(())
116 }
117
118 #[allow(clippy::cast_possible_wrap)]
132 #[tracing::instrument(name = "session.store.update_seq", skip_all, level = "debug")]
133 pub async fn update_seq(
134 &self,
135 session_id: &str,
136 last_seq: u64,
137 event_count: u64,
138 ) -> Result<(), SessionError> {
139 let stmt = zeph_db::rewrite_placeholders(&format!(
140 "UPDATE acp_sessions SET last_seq = ?, event_count = ?, updated_at = {} WHERE id = ?",
141 <ActiveDialect as Dialect>::NOW,
142 ));
143 zeph_db::query(sqlx::AssertSqlSafe(stmt))
144 .bind(last_seq as i64)
145 .bind(event_count as i64)
146 .bind(session_id)
147 .execute(&self.pool)
148 .await?;
149 Ok(())
150 }
151
152 #[tracing::instrument(name = "session.store.set_status", skip_all, level = "debug")]
158 pub async fn set_status(
159 &self,
160 session_id: &str,
161 status: SessionStatus,
162 ) -> Result<(), SessionError> {
163 zeph_db::query(sql!("UPDATE acp_sessions SET status = ? WHERE id = ?"))
164 .bind(status.as_str())
165 .bind(session_id)
166 .execute(&self.pool)
167 .await?;
168 Ok(())
169 }
170
171 #[allow(clippy::cast_possible_wrap)]
177 #[tracing::instrument(name = "session.store.set_condensed_seq", skip_all, level = "debug")]
178 pub async fn set_condensed_seq(
179 &self,
180 session_id: &str,
181 last_condensed_seq: u64,
182 ) -> Result<(), SessionError> {
183 zeph_db::query(sql!(
184 "UPDATE acp_sessions SET last_condensed_seq = ? WHERE id = ?"
185 ))
186 .bind(last_condensed_seq as i64)
187 .bind(session_id)
188 .execute(&self.pool)
189 .await?;
190 Ok(())
191 }
192
193 #[tracing::instrument(name = "session.store.get", skip_all, level = "debug")]
199 pub async fn get(&self, session_id: &str) -> Result<Option<SessionMetadata>, SessionError> {
200 let created_at_sel = <ActiveDialect as Dialect>::select_as_text("created_at");
205 let updated_at_sel = <ActiveDialect as Dialect>::select_as_text("updated_at");
206 let raw = format!(
207 "SELECT id, title, {created_at_sel}, {updated_at_sel}, conversation_id, last_seq, \
208 event_count, forked_from, forked_at_seq, status, last_condensed_seq \
209 FROM acp_sessions WHERE id = ?"
210 );
211 let query_sql = zeph_db::rewrite_placeholders(&raw);
212 let row = zeph_db::query_as::<_, SessionRow>(sqlx::AssertSqlSafe(query_sql))
213 .bind(session_id)
214 .fetch_optional(&self.pool)
215 .await?;
216 row.map(TryInto::try_into).transpose()
217 }
218
219 #[tracing::instrument(name = "session.store.list", skip_all, level = "debug")]
225 pub async fn list(&self, filter: &SessionFilter) -> Result<Vec<SessionMetadata>, SessionError> {
226 let status_filter = filter.status.map(SessionStatus::as_str);
227 let (limit_clause, limit_bind) = zeph_db::limit_clause(filter.limit as u64);
228 let created_at_sel = <ActiveDialect as Dialect>::select_as_text("created_at");
230 let updated_at_sel = <ActiveDialect as Dialect>::select_as_text("updated_at");
231
232 let raw = format!(
233 "SELECT id, title, {created_at_sel}, {updated_at_sel}, conversation_id, last_seq, \
234 event_count, forked_from, forked_at_seq, status, last_condensed_seq \
235 FROM acp_sessions \
236 WHERE (? IS NULL OR status = ?) \
237 ORDER BY updated_at DESC{limit_clause}"
238 );
239 let query_sql = zeph_db::rewrite_placeholders(&raw);
240 let mut query = zeph_db::query_as::<_, SessionRow>(sqlx::AssertSqlSafe(query_sql))
241 .bind(status_filter)
242 .bind(status_filter);
243 if let Some(lim) = limit_bind {
244 query = query.bind(lim);
245 }
246 let rows = query.fetch_all(&self.pool).await?;
247
248 rows.into_iter().map(TryInto::try_into).collect()
249 }
250
251 #[tracing::instrument(name = "session.store.link_conversation", skip_all, level = "debug")]
260 pub async fn link_conversation(
261 &self,
262 session_id: &str,
263 conversation_id: i64,
264 ) -> Result<(), SessionError> {
265 zeph_db::query(sql!(
266 "UPDATE acp_sessions SET conversation_id = ? WHERE id = ?"
267 ))
268 .bind(conversation_id)
269 .bind(session_id)
270 .execute(&self.pool)
271 .await?;
272 Ok(())
273 }
274
275 #[tracing::instrument(
285 name = "session.store.get_by_conversation_id",
286 skip_all,
287 level = "debug"
288 )]
289 pub async fn get_by_conversation_id(
290 &self,
291 conversation_id: i64,
292 ) -> Result<Option<SessionMetadata>, SessionError> {
293 let created_at_sel = <ActiveDialect as Dialect>::select_as_text("created_at");
295 let updated_at_sel = <ActiveDialect as Dialect>::select_as_text("updated_at");
296 let raw = format!(
297 "SELECT id, title, {created_at_sel}, {updated_at_sel}, conversation_id, last_seq, \
298 event_count, forked_from, forked_at_seq, status, last_condensed_seq \
299 FROM acp_sessions WHERE conversation_id = ?"
300 );
301 let query_sql = zeph_db::rewrite_placeholders(&raw);
302 let row = zeph_db::query_as::<_, SessionRow>(sqlx::AssertSqlSafe(query_sql))
303 .bind(conversation_id)
304 .fetch_optional(&self.pool)
305 .await?;
306 row.map(TryInto::try_into).transpose()
307 }
308
309 #[allow(clippy::cast_possible_wrap)]
323 #[tracing::instrument(name = "session.store.record_fork", skip_all, level = "debug")]
324 pub async fn record_fork(
325 &self,
326 new_session_id: &str,
327 src_session_id: &str,
328 forked_at_seq: u64,
329 owner: Option<&str>,
330 ) -> Result<(), SessionError> {
331 let stmt = zeph_db::rewrite_placeholders(&format!(
332 "{} INTO acp_sessions (id, status, forked_from, forked_at_seq, owner_key) \
333 VALUES (?, 'active', ?, ?, ?){}",
334 <ActiveDialect as Dialect>::INSERT_IGNORE,
335 <ActiveDialect as Dialect>::CONFLICT_NOTHING,
336 ));
337 zeph_db::query(sqlx::AssertSqlSafe(stmt))
338 .bind(new_session_id)
339 .bind(src_session_id)
340 .bind(forked_at_seq as i64)
341 .bind(owner)
342 .execute(&self.pool)
343 .await?;
344 Ok(())
345 }
346
347 #[tracing::instrument(name = "session.store.delete", skip_all, level = "debug")]
357 pub async fn delete(&self, session_id: &str) -> Result<bool, SessionError> {
358 let result = zeph_db::query(sql!("DELETE FROM acp_sessions WHERE id = ?"))
359 .bind(session_id)
360 .execute(&self.pool)
361 .await?;
362 Ok(result.rows_affected() > 0)
363 }
364}
365
366#[derive(sqlx::FromRow)]
367struct SessionRow {
368 id: String,
369 title: Option<String>,
370 created_at: String,
371 updated_at: String,
372 conversation_id: Option<i64>,
373 last_seq: i64,
374 event_count: i64,
375 forked_from: Option<String>,
376 forked_at_seq: Option<i64>,
377 status: String,
378 last_condensed_seq: i64,
379}
380
381impl TryFrom<SessionRow> for SessionMetadata {
382 type Error = SessionError;
383
384 fn try_from(row: SessionRow) -> Result<Self, Self::Error> {
385 Ok(Self {
386 session_id: row.id,
387 title: row.title,
388 created_at: row.created_at,
389 updated_at: row.updated_at,
390 conversation_id: row.conversation_id,
391 last_seq: u64::try_from(row.last_seq).unwrap_or(0),
392 event_count: u64::try_from(row.event_count).unwrap_or(0),
393 forked_from: row.forked_from,
394 forked_at_seq: row.forked_at_seq.map(|v| u64::try_from(v).unwrap_or(0)),
395 status: row.status.parse()?,
396 last_condensed_seq: u64::try_from(row.last_condensed_seq).unwrap_or(0),
397 })
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 async fn make_pool() -> DbPool {
406 let config = zeph_db::DbConfig {
407 url: ":memory:".to_owned(),
408 ..Default::default()
409 };
410 let pool = config
411 .connect()
412 .await
413 .expect("connect in-memory sqlite pool");
414 zeph_db::run_migrations(&pool)
415 .await
416 .expect("run migrations");
417 pool
418 }
419
420 #[tokio::test]
421 async fn test_migration_106_idempotent() {
422 let pool = make_pool().await;
423 zeph_db::run_migrations(&pool)
424 .await
425 .expect("second run is a no-op");
426 }
427
428 #[tokio::test]
429 async fn create_and_get_defaults() {
430 let store = SessionStore::new(make_pool().await);
431 store.create("s1").await.unwrap();
432 let meta = store.get("s1").await.unwrap().expect("row exists");
433 assert_eq!(meta.session_id, "s1");
434 assert_eq!(meta.last_seq, 0);
435 assert_eq!(meta.event_count, 0);
436 assert_eq!(meta.status, SessionStatus::Active);
437 assert!(meta.forked_from.is_none());
438 }
439
440 #[tokio::test]
441 async fn update_seq_persists() {
442 let store = SessionStore::new(make_pool().await);
443 store.create("s1").await.unwrap();
444 store.update_seq("s1", 41, 20).await.unwrap();
445 let meta = store.get("s1").await.unwrap().unwrap();
446 assert_eq!(meta.last_seq, 41);
447 assert_eq!(meta.event_count, 20);
448 }
449
450 #[tokio::test]
451 async fn set_status_persists() {
452 let store = SessionStore::new(make_pool().await);
453 store.create("s1").await.unwrap();
454 store.set_status("s1", SessionStatus::Idle).await.unwrap();
455 let meta = store.get("s1").await.unwrap().unwrap();
456 assert_eq!(meta.status, SessionStatus::Idle);
457 }
458
459 #[tokio::test]
460 async fn record_fork_sets_provenance() {
461 let store = SessionStore::new(make_pool().await);
462 store.create("parent").await.unwrap();
463 store
464 .record_fork("child", "parent", 12, None)
465 .await
466 .unwrap();
467 let meta = store.get("child").await.unwrap().unwrap();
468 assert_eq!(meta.forked_from.as_deref(), Some("parent"));
469 assert_eq!(meta.forked_at_seq, Some(12));
470 }
471
472 #[tokio::test]
479 async fn record_fork_stamps_owner_key_on_child_row() {
480 let pool = make_pool().await;
481 let store = SessionStore::new(pool.clone());
482 store.create("parent").await.unwrap();
483 store
484 .record_fork("child", "parent", 12, Some("alice"))
485 .await
486 .unwrap();
487
488 let owner_key: Option<String> =
489 zeph_db::query_scalar(sql!("SELECT owner_key FROM acp_sessions WHERE id = ?"))
490 .bind("child")
491 .fetch_one(&pool)
492 .await
493 .unwrap();
494 assert_eq!(owner_key.as_deref(), Some("alice"));
495 }
496
497 #[tokio::test]
500 async fn record_fork_with_no_owner_leaves_child_row_unowned() {
501 let pool = make_pool().await;
502 let store = SessionStore::new(pool.clone());
503 store.create("parent").await.unwrap();
504 store
505 .record_fork("child", "parent", 12, None)
506 .await
507 .unwrap();
508
509 let owner_key: Option<String> =
510 zeph_db::query_scalar(sql!("SELECT owner_key FROM acp_sessions WHERE id = ?"))
511 .bind("child")
512 .fetch_one(&pool)
513 .await
514 .unwrap();
515 assert!(owner_key.is_none());
516 }
517
518 #[tokio::test]
519 async fn list_filters_by_status() {
520 let store = SessionStore::new(make_pool().await);
521 store.create("s1").await.unwrap();
522 store.create("s2").await.unwrap();
523 store
524 .set_status("s2", SessionStatus::Archived)
525 .await
526 .unwrap();
527
528 let active = store
529 .list(&SessionFilter {
530 status: Some(SessionStatus::Active),
531 limit: 0,
532 })
533 .await
534 .unwrap();
535 assert_eq!(active.len(), 1);
536 assert_eq!(active[0].session_id, "s1");
537
538 let all = store.list(&SessionFilter::default()).await.unwrap();
539 assert_eq!(all.len(), 2);
540 }
541
542 #[tokio::test]
548 async fn list_respects_nonzero_limit() {
549 let store = SessionStore::new(make_pool().await);
550 for i in 0..5u8 {
551 store.create(&format!("s{i}")).await.unwrap();
552 }
553
554 let limited = store
555 .list(&SessionFilter {
556 status: None,
557 limit: 3,
558 })
559 .await
560 .unwrap();
561 assert_eq!(limited.len(), 3);
562 }
563
564 #[tokio::test]
565 async fn delete_removes_row() {
566 let store = SessionStore::new(make_pool().await);
567 store.create("s1").await.unwrap();
568 assert!(store.delete("s1").await.unwrap());
569 assert!(store.get("s1").await.unwrap().is_none());
570 assert!(!store.delete("s1").await.unwrap());
571 }
572
573 #[tokio::test]
574 async fn get_missing_returns_none() {
575 let store = SessionStore::new(make_pool().await);
576 assert!(store.get("no-such").await.unwrap().is_none());
577 }
578
579 #[tokio::test]
580 async fn link_conversation_and_lookup_round_trips() {
581 let pool = make_pool().await;
582 let store = SessionStore::new(pool.clone());
583 store.create("s1").await.unwrap();
584
585 let (cid,): (i64,) =
588 zeph_db::query_as("INSERT INTO conversations DEFAULT VALUES RETURNING id")
589 .fetch_one(&pool)
590 .await
591 .unwrap();
592
593 store.link_conversation("s1", cid).await.unwrap();
594
595 let meta = store.get("s1").await.unwrap().unwrap();
596 assert_eq!(meta.conversation_id, Some(cid));
597
598 let found = store.get_by_conversation_id(cid).await.unwrap().unwrap();
599 assert_eq!(found.session_id, "s1");
600 }
601
602 #[tokio::test]
603 async fn get_by_conversation_id_returns_none_when_unlinked() {
604 let store = SessionStore::new(make_pool().await);
605 store.create("s1").await.unwrap();
606 assert!(store.get_by_conversation_id(99).await.unwrap().is_none());
607 }
608}