mi6_core/model/
storage.rs

1use std::path::Path;
2use std::sync::Arc;
3use std::time::Duration;
4
5use chrono::{DateTime, Utc};
6
7use crate::context::GitBranchInfo;
8use crate::input::transcript::FilePosition;
9use crate::model::session::SessionStatus;
10
11use super::error::StorageError;
12use super::{Event, Session, SessionQuery};
13
14/// Aggregated statistics across all sessions from the storage layer.
15///
16/// Provides a summary of session counts, token usage, costs, and API requests
17/// computed at the database level for efficiency.
18///
19/// Note: This is distinct from `mi6_client::GlobalStats` which includes
20/// runtime OS metrics (CPU, memory) for TUI display.
21#[derive(Debug, Clone, Default, PartialEq)]
22pub struct StorageStats {
23    /// Total number of sessions matching the query
24    pub session_count: u32,
25    /// Number of active sessions (ended_at IS NULL)
26    pub active_session_count: u32,
27    /// Total tokens across all sessions (input + output + cache_read + cache_write)
28    pub total_tokens: i64,
29    /// Total cost in USD across all sessions
30    pub total_cost_usd: f64,
31    /// Total API requests across all sessions
32    pub total_api_requests: u32,
33}
34
35/// Query builder for storage statistics.
36///
37/// `StorageStatsQuery` provides a fluent API for filtering which sessions
38/// are included in the aggregation.
39#[derive(Debug, Clone, Default)]
40pub struct StorageStatsQuery {
41    /// When true, only include active sessions (ended_at IS NULL)
42    pub active_only: bool,
43    /// Filter by framework (claude, cursor, gemini, codex)
44    pub framework: Option<String>,
45}
46
47impl StorageStatsQuery {
48    /// Create a new empty query (includes all sessions)
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Filter to only active sessions
54    pub fn active_only(mut self) -> Self {
55        self.active_only = true;
56        self
57    }
58
59    /// Filter by framework
60    pub fn with_framework(mut self, framework: impl Into<String>) -> Self {
61        self.framework = Some(framework.into());
62        self
63    }
64}
65
66/// Sort order for query results
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum Order {
69    /// Ascending order (oldest first for timestamps, lowest first for IDs)
70    Asc,
71    /// Descending order (newest first for timestamps, highest first for IDs)
72    #[default]
73    Desc,
74}
75
76/// Column to order event query results by.
77///
78/// This enum makes the ordering column explicit, eliminating the previous
79/// implicit behavior where ordering was determined by whether `after_id` was set.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum EventOrder {
82    /// Order by event timestamp (default: newest first)
83    #[default]
84    Timestamp,
85    /// Order by event ID (default: ascending for watching new events)
86    Id,
87}
88
89/// Composable query builder for event retrieval.
90///
91/// `EventQuery` provides a fluent API for constructing event queries with
92/// filters, ordering, and pagination.
93///
94/// # Ordering Behavior
95///
96/// The query provides **explicit ordering** via the `order_by` field:
97/// - [`EventOrder::Timestamp`]: Order by event timestamp (default)
98/// - [`EventOrder::Id`]: Order by event ID
99///
100/// Use `order_by_timestamp()` or `order_by_id()` to set the ordering column,
101/// and `with_direction()` to control ascending/descending order.
102///
103/// # Use Cases
104///
105/// - **Recent events**: Use `order_by_timestamp()` with `Order::Desc` (default)
106/// - **Watching new events**: Use `order_by_id()` with `after_id` for pagination
107///
108/// # Defaults
109///
110/// - `order_by`: [`EventOrder::Timestamp`] - orders by timestamp
111/// - `direction`: [`Order::Desc`] for timestamp, [`Order::Asc`] for ID
112///
113/// # Example
114/// ```ignore
115/// // Get 50 most recent events for a session (orders by timestamp DESC)
116/// let query = EventQuery::new()
117///     .order_by_timestamp()
118///     .with_session(Some("session-123".to_string()))
119///     .with_limit(50);
120///
121/// // Watch for new events after ID 100 (orders by ID ASC)
122/// let query = EventQuery::new()
123///     .order_by_id()
124///     .with_after_id(100)
125///     .with_limit(10);
126/// ```
127#[derive(Default, Clone)]
128pub struct EventQuery {
129    /// Filter by session ID (mutually exclusive with `session_ids`)
130    pub session_id: Option<String>,
131    /// Filter by multiple session IDs (mutually exclusive with `session_id`)
132    pub session_ids: Option<Vec<String>>,
133    /// Filter by event type (case-insensitive)
134    pub event_type: Option<String>,
135    /// Filter by permission mode
136    pub permission_mode: Option<String>,
137    /// Filter by framework (claude, cursor, gemini)
138    pub framework: Option<String>,
139    /// Filter events after this timestamp (exclusive)
140    pub after_ts: Option<DateTime<Utc>>,
141    /// Filter events before this timestamp (exclusive)
142    pub before_ts: Option<DateTime<Utc>>,
143    /// Filter events after this ID (exclusive)
144    pub after_id: Option<i64>,
145    /// Maximum number of events to return
146    pub limit: Option<usize>,
147    /// Column to order results by (default: Timestamp)
148    pub order_by: EventOrder,
149    /// Sort direction (default: Desc for timestamp, Asc for ID)
150    pub direction: Option<Order>,
151    /// When true, only return API request events (events with token data)
152    pub api_requests_only: bool,
153    /// When true, exclude API request events
154    pub exclude_api_requests: bool,
155}
156
157impl EventQuery {
158    /// Create a new empty query.
159    ///
160    /// The query defaults to ordering by timestamp (descending).
161    /// Use `order_by_id()` for ID-based ordering (e.g., when watching for new events).
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Order results by timestamp.
167    ///
168    /// This is the default ordering and is suitable for viewing recent events.
169    /// Default direction is [`Order::Desc`] (newest first).
170    ///
171    /// # Example
172    /// ```ignore
173    /// let query = EventQuery::new()
174    ///     .order_by_timestamp()
175    ///     .with_limit(50);
176    /// ```
177    pub fn order_by_timestamp(mut self) -> Self {
178        self.order_by = EventOrder::Timestamp;
179        self
180    }
181
182    /// Order results by ID.
183    ///
184    /// Use this when watching for new events with `after_id` pagination.
185    /// Default direction is [`Order::Asc`] (oldest first, for forward pagination).
186    ///
187    /// # Example
188    /// ```ignore
189    /// let query = EventQuery::new()
190    ///     .order_by_id()
191    ///     .with_after_id(100)
192    ///     .with_limit(10);
193    /// ```
194    pub fn order_by_id(mut self) -> Self {
195        self.order_by = EventOrder::Id;
196        self
197    }
198
199    /// Filter events after the given timestamp (exclusive)
200    pub fn with_after_ts(mut self, ts: DateTime<Utc>) -> Self {
201        self.after_ts = Some(ts);
202        self
203    }
204
205    /// Filter events before the given timestamp (exclusive)
206    pub fn with_before_ts(mut self, ts: DateTime<Utc>) -> Self {
207        self.before_ts = Some(ts);
208        self
209    }
210
211    /// Filter events between two timestamps (exclusive on both ends).
212    ///
213    /// Note: If `start >= end`, the filter will return no results.
214    /// The caller is responsible for ensuring `start < end` for meaningful queries.
215    pub fn with_between_ts(mut self, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
216        self.after_ts = Some(start);
217        self.before_ts = Some(end);
218        self
219    }
220
221    /// Filter events after the given ID (exclusive).
222    ///
223    /// Use with `order_by_id()` for consistent pagination when watching for new events.
224    ///
225    /// # Example
226    /// ```ignore
227    /// let query = EventQuery::new()
228    ///     .order_by_id()
229    ///     .with_after_id(last_seen_id)
230    ///     .with_limit(10);
231    /// ```
232    pub fn with_after_id(mut self, id: i64) -> Self {
233        self.after_id = Some(id);
234        self
235    }
236
237    /// Set the maximum number of events to return
238    pub fn with_limit(mut self, limit: usize) -> Self {
239        self.limit = Some(limit);
240        self
241    }
242
243    /// Set the sort direction (Asc/Desc).
244    ///
245    /// This controls whether results are in ascending or descending order.
246    /// If not set, defaults to [`Order::Desc`] for timestamp ordering
247    /// and [`Order::Asc`] for ID ordering.
248    pub fn with_direction(mut self, direction: Order) -> Self {
249        self.direction = Some(direction);
250        self
251    }
252
253    /// Set session filter if value is Some.
254    ///
255    /// Note: This clears any previously set `session_ids` filter.
256    pub fn with_session(mut self, session_id: Option<String>) -> Self {
257        self.session_id = session_id;
258        self.session_ids = None;
259        self
260    }
261
262    /// Filter events by multiple session IDs.
263    ///
264    /// Use this to efficiently query events for multiple sessions in a single
265    /// database query, avoiding the N+1 query problem.
266    ///
267    /// Note: This clears any previously set `session_id` filter.
268    /// Passing an empty slice will result in no events being returned.
269    ///
270    /// # Example
271    /// ```ignore
272    /// // Get events for multiple sessions at once
273    /// let events = storage.query(
274    ///     &EventQuery::new()
275    ///         .with_sessions(&["session-1", "session-2", "session-3"])
276    ///         .with_event_type(Some("UserPromptSubmit".to_string()))
277    ///         .with_limit(100)
278    /// )?;
279    /// ```
280    pub fn with_sessions(mut self, session_ids: &[impl AsRef<str>]) -> Self {
281        // Store the session_ids - even empty vec is stored to distinguish from None
282        self.session_ids = Some(session_ids.iter().map(|s| s.as_ref().to_string()).collect());
283        self.session_id = None;
284        self
285    }
286
287    /// Set event type filter if value is Some
288    pub fn with_event_type(mut self, event_type: Option<String>) -> Self {
289        self.event_type = event_type;
290        self
291    }
292
293    /// Set permission mode filter if value is Some
294    pub fn with_permission_mode(mut self, permission_mode: Option<String>) -> Self {
295        self.permission_mode = permission_mode;
296        self
297    }
298
299    /// Filter by framework
300    pub fn with_framework(mut self, framework: impl Into<String>) -> Self {
301        self.framework = Some(framework.into());
302        self
303    }
304
305    /// Only return API request events (events with token data)
306    pub fn api_requests_only(mut self) -> Self {
307        self.api_requests_only = true;
308        self.exclude_api_requests = false;
309        self
310    }
311
312    /// Exclude API request events from results
313    pub fn exclude_api_requests(mut self) -> Self {
314        self.exclude_api_requests = true;
315        self.api_requests_only = false;
316        self
317    }
318
319    /// Returns the effective sort direction for this query.
320    ///
321    /// Storage implementations should use this method to determine the sort
322    /// direction when executing queries.
323    ///
324    /// # Returns
325    /// - If `direction` is explicitly set, returns that value.
326    /// - For [`EventOrder::Id`], defaults to [`Order::Asc`] (for watching new events).
327    /// - For [`EventOrder::Timestamp`], defaults to [`Order::Desc`] (most recent first).
328    ///
329    /// # Example
330    /// ```
331    /// use mi6_core::{EventQuery, Order};
332    ///
333    /// // Default: Desc for timestamp ordering
334    /// let query = EventQuery::new();
335    /// assert_eq!(query.effective_direction(), Order::Desc);
336    ///
337    /// // With order_by_id: Asc for watching
338    /// let query = EventQuery::new().order_by_id();
339    /// assert_eq!(query.effective_direction(), Order::Asc);
340    ///
341    /// // Explicit direction overrides default
342    /// let query = EventQuery::new().with_direction(Order::Asc);
343    /// assert_eq!(query.effective_direction(), Order::Asc);
344    /// ```
345    pub fn effective_direction(&self) -> Order {
346        self.direction.unwrap_or(match self.order_by {
347            EventOrder::Id => Order::Asc,
348            EventOrder::Timestamp => Order::Desc,
349        })
350    }
351
352    /// Returns true if this query should order by ID instead of timestamp.
353    ///
354    /// Storage implementations should use this method to determine which column
355    /// to use for ordering results.
356    ///
357    /// # Example
358    /// ```
359    /// use mi6_core::EventQuery;
360    ///
361    /// // Default: order by timestamp
362    /// let query = EventQuery::new();
363    /// assert!(!query.orders_by_id());
364    ///
365    /// // With order_by_id(): order by ID
366    /// let query = EventQuery::new().order_by_id();
367    /// assert!(query.orders_by_id());
368    /// ```
369    pub fn orders_by_id(&self) -> bool {
370        matches!(self.order_by, EventOrder::Id)
371    }
372}
373
374/// Storage backend trait for mi6 events
375///
376/// All events (including API requests) are stored in a unified events table.
377/// API requests are distinguished by having the `ApiRequest` event type and
378/// populated token fields.
379pub trait Storage {
380    /// Insert a new event, returns the event ID
381    fn insert(&self, event: &Event) -> Result<i64, StorageError>;
382
383    /// Query events using the composable EventQuery builder.
384    ///
385    /// This is the primary method for retrieving events. Use `EventQuery` to
386    /// specify filters, ordering, and pagination.
387    ///
388    /// # Ordering
389    /// - Use `order_by_timestamp()` for recent events (default, descending)
390    /// - Use `order_by_id()` for watching new events (ascending by default)
391    ///
392    /// # API Requests
393    /// Use `api_requests_only()` to filter to only API request events,
394    /// or `exclude_api_requests()` to exclude them.
395    ///
396    /// # Examples
397    /// ```ignore
398    /// // Get 50 most recent events (including API requests)
399    /// let events = storage.query(&EventQuery::new().order_by_timestamp().with_limit(50))?;
400    ///
401    /// // Get events for a specific session
402    /// let events = storage.query(&EventQuery::new().with_session(Some("sess-123".to_string())).with_limit(100))?;
403    ///
404    /// // Watch for new events after a known ID
405    /// let events = storage.query(&EventQuery::new().order_by_id().with_after_id(last_id).with_limit(10))?;
406    ///
407    /// // Get only API request events
408    /// let api_events = storage.query(&EventQuery::new().api_requests_only().with_limit(50))?;
409    /// ```
410    fn query(&self, query: &EventQuery) -> Result<Vec<Event>, StorageError>;
411
412    /// Delete events older than the retention period, returns count deleted.
413    fn gc(&self, retention: Duration) -> Result<usize, StorageError>;
414
415    /// Count events older than the retention period (for dry-run).
416    fn count_expired(&self, retention: Duration) -> Result<usize, StorageError>;
417
418    /// Count total events
419    fn count(&self) -> Result<usize, StorageError>;
420
421    /// List sessions matching the query criteria.
422    ///
423    /// Sessions contain denormalized metadata that is incrementally updated
424    /// when events are inserted. This enables single-query monitoring by
425    /// returning all session data in one call.
426    ///
427    /// # Examples
428    /// ```ignore
429    /// // Get all active sessions, most recent first
430    /// let sessions = storage.list_sessions(&SessionQuery::new().active_only())?;
431    ///
432    /// // Get sessions for a specific framework
433    /// let sessions = storage.list_sessions(&SessionQuery::new().with_framework("claude"))?;
434    ///
435    /// // Get 10 most recently active sessions
436    /// let sessions = storage.list_sessions(
437    ///     &SessionQuery::new()
438    ///         .with_order_by(SessionOrder::LastActivity)
439    ///         .with_limit(10)
440    /// )?;
441    /// ```
442    fn list_sessions(&self, query: &SessionQuery) -> Result<Vec<Session>, StorageError>;
443
444    /// Get a single session by ID.
445    ///
446    /// This queries by session_id alone. If multiple sessions exist with the same
447    /// session_id (on different machines), returns the most recently active one.
448    ///
449    /// For exact lookups with a known machine_id, use [`get_session_by_key`].
450    ///
451    /// Returns `Ok(None)` if no session with the given ID exists.
452    fn get_session(&self, session_id: &str) -> Result<Option<Session>, StorageError>;
453
454    /// Get a single session by composite key (machine_id, session_id).
455    ///
456    /// This is the exact lookup using the composite primary key, which ensures
457    /// uniqueness across multiple machines.
458    ///
459    /// Returns `Ok(None)` if no session with the given key exists.
460    fn get_session_by_key(
461        &self,
462        machine_id: &str,
463        session_id: &str,
464    ) -> Result<Option<Session>, StorageError>;
465
466    /// Get a session by its process ID.
467    ///
468    /// Returns the most recently active session associated with the given PID.
469    /// This is useful for looking up sessions when only the PID is known
470    /// (e.g., from process monitoring tools).
471    ///
472    /// Returns `Ok(None)` if no session with the given PID exists.
473    fn get_session_by_pid(&self, pid: i32) -> Result<Option<Session>, StorageError>;
474
475    /// Update git branch information for a session.
476    ///
477    /// This updates the session's git_branch, git_pr_number, and git_issue_number
478    /// fields. It is typically called:
479    /// - On SessionStart, to capture the initial branch
480    /// - After detecting a git branch-changing command in PostToolUse
481    ///
482    /// Returns `Ok(false)` if the session doesn't exist.
483    fn update_session_git_info(
484        &self,
485        session_id: &str,
486        git_info: &GitBranchInfo,
487    ) -> Result<bool, StorageError>;
488
489    /// Update the GitHub repository for a session.
490    ///
491    /// This updates the session's github_repo field if it's currently empty.
492    /// Uses COALESCE semantics to avoid overwriting existing values.
493    ///
494    /// It is typically called:
495    /// - On SessionStart, to capture the initial repo from git remote
496    /// - When a worktree is detected
497    ///
498    /// Returns `Ok(false)` if the session doesn't exist.
499    fn update_session_github_repo(
500        &self,
501        session_id: &str,
502        github_repo: &str,
503    ) -> Result<bool, StorageError>;
504
505    /// Upsert git directory path and GitHub repository for a session.
506    ///
507    /// This is called on every hook event BEFORE the event is inserted, to ensure
508    /// git context is updated first. This allows branch parsing (which happens
509    /// during event insert) to correctly set github_issue/github_pr for the
510    /// current repo.
511    ///
512    /// When github_repo changes, github_issue and github_pr are cleared because
513    /// issue/PR numbers are only meaningful in the context of a specific repository.
514    ///
515    /// Creates the session if it doesn't exist (UPSERT semantics).
516    ///
517    /// This is safe to call frequently (~50µs with direct file access).
518    fn upsert_session_git_context(
519        &self,
520        session_id: &str,
521        machine_id: &str,
522        framework: &str,
523        timestamp: i64,
524        local_git_dir: Option<&str>,
525        github_repo: Option<&str>,
526    ) -> Result<(), StorageError>;
527
528    /// Update the transcript path for a session.
529    ///
530    /// This updates the session's transcript_path field if it's currently empty.
531    /// Uses COALESCE semantics to avoid overwriting existing values.
532    ///
533    /// This is primarily used for Codex sessions where the transcript path
534    /// is not available from hooks but is known from session file scanning.
535    ///
536    /// Returns `Ok(false)` if the session doesn't exist.
537    fn update_session_transcript_path(
538        &self,
539        machine_id: &str,
540        session_id: &str,
541        transcript_path: &str,
542    ) -> Result<bool, StorageError>;
543
544    /// Update the prompt (first_user_message) for a session.
545    ///
546    /// This allows users to rename sessions in the TUI for easier identification.
547    /// Unlike other session updates, this overwrites any existing value.
548    ///
549    /// Returns `Ok(false)` if the session doesn't exist.
550    fn update_session_prompt(
551        &self,
552        machine_id: &str,
553        session_id: &str,
554        prompt: &str,
555    ) -> Result<bool, StorageError>;
556
557    /// Update the status for an Amp session.
558    ///
559    /// This updates the session's status field based on thread file state detection.
560    /// Uses timestamp-guarded updates to prevent stale data from overwriting newer status.
561    ///
562    /// This is only called for Amp sessions since other frameworks use hooks for status.
563    ///
564    /// Returns `Ok(false)` if the session doesn't exist.
565    fn update_amp_session_status(
566        &self,
567        machine_id: &str,
568        session_id: &str,
569        timestamp: i64,
570        status: SessionStatus,
571    ) -> Result<bool, StorageError>;
572
573    /// Get aggregated statistics across all sessions.
574    ///
575    /// Returns session counts, token totals, costs, and API request counts
576    /// computed at the database level via a single aggregation query. This is
577    /// more efficient than iterating over individual sessions in application code.
578    ///
579    /// # Examples
580    /// ```ignore
581    /// // Get stats for all sessions
582    /// let stats = storage.storage_stats(&StorageStatsQuery::new())?;
583    /// println!("Total sessions: {}", stats.session_count);
584    /// println!("Active sessions: {}", stats.active_session_count);
585    /// println!("Total cost: ${:.2}", stats.total_cost_usd);
586    ///
587    /// // Get stats for only active sessions
588    /// let stats = storage.storage_stats(&StorageStatsQuery::new().active_only())?;
589    ///
590    /// // Get stats for a specific framework
591    /// let stats = storage.storage_stats(&StorageStatsQuery::new().with_framework("claude"))?;
592    /// ```
593    fn storage_stats(&self, query: &StorageStatsQuery) -> Result<StorageStats, StorageError>;
594
595    // ========================================================================
596    // Transcript scanning methods (optional, with default no-op implementations)
597    // ========================================================================
598
599    /// Get the last scanned position for a transcript file.
600    ///
601    /// Returns `Ok(None)` if the file has never been scanned.
602    /// Default implementation returns `Ok(None)`.
603    fn get_transcript_position(&self, _path: &Path) -> Result<Option<FilePosition>, StorageError> {
604        Ok(None)
605    }
606
607    /// Set the scanned position for a transcript file.
608    ///
609    /// Default implementation does nothing.
610    fn set_transcript_position(
611        &self,
612        _path: &Path,
613        _position: &FilePosition,
614    ) -> Result<(), StorageError> {
615        Ok(())
616    }
617
618    /// Check if an event with the given UUID already exists for a session.
619    ///
620    /// Used for deduplication when scanning transcripts.
621    /// Default implementation returns `Ok(false)`.
622    fn event_exists_by_uuid(&self, _session_id: &str, _uuid: &str) -> Result<bool, StorageError> {
623        Ok(false)
624    }
625
626    /// Query all transcript file positions.
627    ///
628    /// Returns a list of (path, position) tuples for all tracked transcript files.
629    /// Default implementation returns an empty list.
630    fn query_transcript_positions(&self) -> Result<Vec<(String, FilePosition)>, StorageError> {
631        Ok(vec![])
632    }
633}
634
635/// Blanket implementation of `Storage` for `Arc<T>` where `T: Storage`.
636///
637/// This enables shared ownership of storage instances. Wrap your storage in an
638/// `Arc` and it can be used anywhere a `Storage` implementation is expected.
639///
640/// # Use Cases
641///
642/// - Sharing storage across multiple owned structs
643/// - Single-threaded async runtimes (e.g., `tokio::task::LocalSet`)
644/// - Multi-threaded access when `T: Send + Sync` (see note below)
645///
646/// # Thread Safety Note
647///
648/// For multi-threaded use, `T` must be `Send + Sync`. `SqliteStorage` is not
649/// `Sync` (since `rusqlite::Connection` is not `Sync`), so `Arc<SqliteStorage>`
650/// cannot be shared across threads directly. For multi-threaded scenarios with
651/// `SqliteStorage`, use `Arc<Mutex<SqliteStorage>>` or a connection pool.
652///
653/// # Example
654/// ```ignore
655/// use mi6_storage_sqlite::SqliteStorage;
656/// use mi6_core::{Storage, EventQuery};
657/// use std::sync::Arc;
658///
659/// // Create shared storage
660/// let storage = Arc::new(SqliteStorage::open(path)?);
661///
662/// // Clone for use in multiple places (single-threaded)
663/// let storage_for_queries = Arc::clone(&storage);
664/// let storage_for_inserts = Arc::clone(&storage);
665///
666/// // Both references can use Storage trait methods
667/// let events = storage_for_queries.query(&EventQuery::new().with_limit(10))?;
668/// storage_for_inserts.insert(&event)?;
669/// ```
670impl<T: Storage> Storage for Arc<T> {
671    fn insert(&self, event: &Event) -> Result<i64, StorageError> {
672        (**self).insert(event)
673    }
674
675    fn query(&self, query: &EventQuery) -> Result<Vec<Event>, StorageError> {
676        (**self).query(query)
677    }
678
679    fn gc(&self, retention: Duration) -> Result<usize, StorageError> {
680        (**self).gc(retention)
681    }
682
683    fn count_expired(&self, retention: Duration) -> Result<usize, StorageError> {
684        (**self).count_expired(retention)
685    }
686
687    fn count(&self) -> Result<usize, StorageError> {
688        (**self).count()
689    }
690
691    fn list_sessions(&self, query: &SessionQuery) -> Result<Vec<Session>, StorageError> {
692        (**self).list_sessions(query)
693    }
694
695    fn get_session(&self, session_id: &str) -> Result<Option<Session>, StorageError> {
696        (**self).get_session(session_id)
697    }
698
699    fn get_session_by_key(
700        &self,
701        machine_id: &str,
702        session_id: &str,
703    ) -> Result<Option<Session>, StorageError> {
704        (**self).get_session_by_key(machine_id, session_id)
705    }
706
707    fn get_session_by_pid(&self, pid: i32) -> Result<Option<Session>, StorageError> {
708        (**self).get_session_by_pid(pid)
709    }
710
711    fn update_session_git_info(
712        &self,
713        session_id: &str,
714        git_info: &GitBranchInfo,
715    ) -> Result<bool, StorageError> {
716        (**self).update_session_git_info(session_id, git_info)
717    }
718
719    fn update_session_github_repo(
720        &self,
721        session_id: &str,
722        github_repo: &str,
723    ) -> Result<bool, StorageError> {
724        (**self).update_session_github_repo(session_id, github_repo)
725    }
726
727    fn upsert_session_git_context(
728        &self,
729        session_id: &str,
730        machine_id: &str,
731        framework: &str,
732        timestamp: i64,
733        local_git_dir: Option<&str>,
734        github_repo: Option<&str>,
735    ) -> Result<(), StorageError> {
736        (**self).upsert_session_git_context(
737            session_id,
738            machine_id,
739            framework,
740            timestamp,
741            local_git_dir,
742            github_repo,
743        )
744    }
745
746    fn update_session_transcript_path(
747        &self,
748        machine_id: &str,
749        session_id: &str,
750        transcript_path: &str,
751    ) -> Result<bool, StorageError> {
752        (**self).update_session_transcript_path(machine_id, session_id, transcript_path)
753    }
754
755    fn update_session_prompt(
756        &self,
757        machine_id: &str,
758        session_id: &str,
759        prompt: &str,
760    ) -> Result<bool, StorageError> {
761        (**self).update_session_prompt(machine_id, session_id, prompt)
762    }
763
764    fn update_amp_session_status(
765        &self,
766        machine_id: &str,
767        session_id: &str,
768        timestamp: i64,
769        status: SessionStatus,
770    ) -> Result<bool, StorageError> {
771        (**self).update_amp_session_status(machine_id, session_id, timestamp, status)
772    }
773
774    fn storage_stats(&self, query: &StorageStatsQuery) -> Result<StorageStats, StorageError> {
775        (**self).storage_stats(query)
776    }
777
778    fn get_transcript_position(&self, path: &Path) -> Result<Option<FilePosition>, StorageError> {
779        (**self).get_transcript_position(path)
780    }
781
782    fn set_transcript_position(
783        &self,
784        path: &Path,
785        position: &FilePosition,
786    ) -> Result<(), StorageError> {
787        (**self).set_transcript_position(path, position)
788    }
789
790    fn event_exists_by_uuid(&self, session_id: &str, uuid: &str) -> Result<bool, StorageError> {
791        (**self).event_exists_by_uuid(session_id, uuid)
792    }
793
794    fn query_transcript_positions(&self) -> Result<Vec<(String, FilePosition)>, StorageError> {
795        (**self).query_transcript_positions()
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::StringError;
803    use std::error::Error;
804
805    #[test]
806    fn test_error_source_preserved() {
807        // Create an underlying IO error
808        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
809        let storage_err = StorageError::Connection(Box::new(io_err));
810
811        // Verify the error chain is preserved
812        let source = storage_err.source();
813        assert!(source.is_some(), "StorageError should have a source");
814        assert!(source.is_some_and(|s| s.to_string().contains("file not found")));
815    }
816
817    #[test]
818    fn test_string_error_works() {
819        let msg = "custom error message";
820        let storage_err = StorageError::Query(Box::new(StringError(msg.to_string())));
821
822        // Verify the error message is preserved
823        assert!(storage_err.to_string().contains(msg));
824        assert!(storage_err.source().is_some());
825    }
826}