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