Skip to main content

sc/storage/
events.rs

1//! Audit event storage and retrieval.
2//!
3//! Events track all mutations in the database for debugging and history.
4
5use rusqlite::{Connection, Result};
6
7/// Event types for audit logging.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum EventType {
10    // Session events
11    SessionCreated,
12    SessionUpdated,
13    SessionPaused,
14    SessionCompleted,
15    SessionDeleted,
16    SessionPathAdded,
17    SessionPathRemoved,
18
19    // Context item events
20    ItemCreated,
21    ItemUpdated,
22    ItemDeleted,
23
24    // Issue events
25    IssueCreated,
26    IssueUpdated,
27    IssueClosed,
28    IssueClaimed,
29    IssueReleased,
30    IssueDeleted,
31
32    // Checkpoint events
33    CheckpointCreated,
34    CheckpointRestored,
35    CheckpointDeleted,
36
37    // Plan events
38    PlanCreated,
39    PlanUpdated,
40    PlanCompleted,
41
42    // Memory events
43    MemorySaved,
44    MemoryDeleted,
45
46    // Project events
47    ProjectCreated,
48    ProjectUpdated,
49    ProjectDeleted,
50}
51
52impl EventType {
53    /// Get the string representation for storage.
54    #[must_use]
55    pub const fn as_str(&self) -> &'static str {
56        match self {
57            Self::SessionCreated => "session_created",
58            Self::SessionUpdated => "session_updated",
59            Self::SessionPaused => "session_paused",
60            Self::SessionCompleted => "session_completed",
61            Self::SessionDeleted => "session_deleted",
62            Self::SessionPathAdded => "session_path_added",
63            Self::SessionPathRemoved => "session_path_removed",
64            Self::ItemCreated => "item_created",
65            Self::ItemUpdated => "item_updated",
66            Self::ItemDeleted => "item_deleted",
67            Self::IssueCreated => "issue_created",
68            Self::IssueUpdated => "issue_updated",
69            Self::IssueClosed => "issue_closed",
70            Self::IssueClaimed => "issue_claimed",
71            Self::IssueReleased => "issue_released",
72            Self::IssueDeleted => "issue_deleted",
73            Self::CheckpointCreated => "checkpoint_created",
74            Self::CheckpointRestored => "checkpoint_restored",
75            Self::CheckpointDeleted => "checkpoint_deleted",
76            Self::PlanCreated => "plan_created",
77            Self::PlanUpdated => "plan_updated",
78            Self::PlanCompleted => "plan_completed",
79            Self::MemorySaved => "memory_saved",
80            Self::MemoryDeleted => "memory_deleted",
81            Self::ProjectCreated => "project_created",
82            Self::ProjectUpdated => "project_updated",
83            Self::ProjectDeleted => "project_deleted",
84        }
85    }
86}
87
88/// An audit event record.
89#[derive(Debug, Clone)]
90pub struct Event {
91    pub id: i64,
92    pub entity_type: String,
93    pub entity_id: String,
94    pub event_type: EventType,
95    pub actor: String,
96    pub old_value: Option<String>,
97    pub new_value: Option<String>,
98    pub comment: Option<String>,
99    pub created_at: i64,
100}
101
102impl Event {
103    /// Create a new event (id will be assigned by database).
104    #[must_use]
105    pub fn new(
106        entity_type: &str,
107        entity_id: &str,
108        event_type: EventType,
109        actor: &str,
110    ) -> Self {
111        Self {
112            id: 0,
113            entity_type: entity_type.to_string(),
114            entity_id: entity_id.to_string(),
115            event_type,
116            actor: actor.to_string(),
117            old_value: None,
118            new_value: None,
119            comment: None,
120            created_at: chrono::Utc::now().timestamp_millis(),
121        }
122    }
123
124    /// Add old/new values for field change tracking.
125    #[must_use]
126    pub fn with_values(mut self, old: Option<String>, new: Option<String>) -> Self {
127        self.old_value = old;
128        self.new_value = new;
129        self
130    }
131
132    /// Add a comment to the event.
133    #[must_use]
134    pub fn with_comment(mut self, comment: &str) -> Self {
135        self.comment = Some(comment.to_string());
136        self
137    }
138}
139
140/// Insert an event into the database.
141///
142/// # Errors
143///
144/// Returns an error if the insert fails.
145pub fn insert_event(conn: &Connection, event: &Event) -> Result<i64> {
146    conn.execute(
147        "INSERT INTO events (entity_type, entity_id, event_type, actor, old_value, new_value, comment, created_at)
148         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
149        rusqlite::params![
150            event.entity_type,
151            event.entity_id,
152            event.event_type.as_str(),
153            event.actor,
154            event.old_value,
155            event.new_value,
156            event.comment,
157            event.created_at,
158        ],
159    )?;
160    Ok(conn.last_insert_rowid())
161}
162
163/// Get events for an entity.
164///
165/// # Errors
166///
167/// Returns an error if the query fails.
168pub fn get_events(
169    conn: &Connection,
170    entity_type: &str,
171    entity_id: &str,
172    limit: Option<u32>,
173) -> Result<Vec<Event>> {
174    let limit = limit.unwrap_or(100);
175    let mut stmt = conn.prepare(
176        "SELECT id, entity_type, entity_id, event_type, actor, old_value, new_value, comment, created_at
177         FROM events
178         WHERE entity_type = ?1 AND entity_id = ?2
179         ORDER BY created_at DESC
180         LIMIT ?3",
181    )?;
182
183    let rows = stmt.query_map(rusqlite::params![entity_type, entity_id, limit], |row| {
184        Ok(Event {
185            id: row.get(0)?,
186            entity_type: row.get(1)?,
187            entity_id: row.get(2)?,
188            event_type: parse_event_type(row.get::<_, String>(3)?.as_str()),
189            actor: row.get(4)?,
190            old_value: row.get(5)?,
191            new_value: row.get(6)?,
192            comment: row.get(7)?,
193            created_at: row.get(8)?,
194        })
195    })?;
196
197    rows.collect()
198}
199
200fn parse_event_type(s: &str) -> EventType {
201    match s {
202        "session_created" => EventType::SessionCreated,
203        "session_updated" => EventType::SessionUpdated,
204        "session_paused" => EventType::SessionPaused,
205        "session_completed" => EventType::SessionCompleted,
206        "session_deleted" => EventType::SessionDeleted,
207        "session_path_added" => EventType::SessionPathAdded,
208        "session_path_removed" => EventType::SessionPathRemoved,
209        "item_created" => EventType::ItemCreated,
210        "item_updated" => EventType::ItemUpdated,
211        "item_deleted" => EventType::ItemDeleted,
212        "issue_created" => EventType::IssueCreated,
213        "issue_updated" => EventType::IssueUpdated,
214        "issue_closed" => EventType::IssueClosed,
215        "issue_claimed" => EventType::IssueClaimed,
216        "issue_released" => EventType::IssueReleased,
217        "issue_deleted" => EventType::IssueDeleted,
218        "checkpoint_created" => EventType::CheckpointCreated,
219        "checkpoint_restored" => EventType::CheckpointRestored,
220        "checkpoint_deleted" => EventType::CheckpointDeleted,
221        "plan_created" => EventType::PlanCreated,
222        "plan_updated" => EventType::PlanUpdated,
223        "plan_completed" => EventType::PlanCompleted,
224        "memory_saved" => EventType::MemorySaved,
225        "memory_deleted" => EventType::MemoryDeleted,
226        "project_created" => EventType::ProjectCreated,
227        "project_updated" => EventType::ProjectUpdated,
228        "project_deleted" => EventType::ProjectDeleted,
229        _ => EventType::SessionUpdated, // Fallback
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::storage::schema::apply_schema;
237
238    #[test]
239    fn test_event_insert_and_get() {
240        let conn = Connection::open_in_memory().unwrap();
241        apply_schema(&conn).unwrap();
242
243        let event = Event::new("session", "sess_123", EventType::SessionCreated, "test-actor")
244            .with_comment("Test session created");
245
246        let id = insert_event(&conn, &event).unwrap();
247        assert!(id > 0);
248
249        let events = get_events(&conn, "session", "sess_123", Some(10)).unwrap();
250        assert_eq!(events.len(), 1);
251        assert_eq!(events[0].actor, "test-actor");
252        assert_eq!(events[0].comment, Some("Test session created".to_string()));
253    }
254}