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}