Skip to main content

confium_coordinator/
event_sourced.rs

1//! Event-sourced session store.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::sync::Mutex;
6
7/// A session event.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(tag = "type", rename_all = "snake_case")]
10pub enum SessionEvent {
11    Created {
12        session_id: String,
13        threshold: u32,
14        party_count: u32,
15    },
16    CommitmentSubmitted {
17        session_id: String,
18        signer_id: String,
19    },
20    ShareSubmitted {
21        session_id: String,
22        signer_id: String,
23    },
24    Completed {
25        session_id: String,
26    },
27    Expired {
28        session_id: String,
29    },
30    Aborted {
31        session_id: String,
32        reason: String,
33    },
34}
35
36/// An event log entry with metadata.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct EventEntry {
39    pub sequence: u64,
40    pub timestamp: DateTime<Utc>,
41    pub event: SessionEvent,
42}
43
44/// Event-sourced session state projected from events.
45#[derive(Debug, Clone, Default)]
46pub struct SessionProjection {
47    pub session_id: String,
48    pub state: String,
49    pub threshold: u32,
50    pub party_count: u32,
51    pub commitments: Vec<String>,
52    pub shares: Vec<String>,
53}
54
55/// In-memory event store.
56#[derive(Default)]
57pub struct EventStore {
58    events: Mutex<Vec<EventEntry>>,
59    next_seq: Mutex<u64>,
60}
61
62impl EventStore {
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    pub fn append(&self, event: SessionEvent) -> u64 {
68        let seq = {
69            let mut next = self.next_seq.lock().unwrap();
70            let s = *next;
71            *next += 1;
72            s
73        };
74        self.events.lock().unwrap().push(EventEntry {
75            sequence: seq,
76            timestamp: Utc::now(),
77            event,
78        });
79        seq
80    }
81
82    pub fn all_events(&self) -> Vec<EventEntry> {
83        self.events.lock().unwrap().clone()
84    }
85
86    pub fn events_for_session(&self, session_id: &str) -> Vec<EventEntry> {
87        self.events
88            .lock()
89            .unwrap()
90            .iter()
91            .filter(|e| session_matches(&e.event, session_id))
92            .cloned()
93            .collect()
94    }
95
96    pub fn project_session(&self, session_id: &str) -> Option<SessionProjection> {
97        let events = self.events_for_session(session_id);
98        if events.is_empty() {
99            return None;
100        }
101        let mut proj = SessionProjection {
102            session_id: session_id.into(),
103            ..Default::default()
104        };
105        for entry in &events {
106            apply_event(&mut proj, &entry.event);
107        }
108        Some(proj)
109    }
110
111    pub fn project_all(&self) -> Vec<SessionProjection> {
112        let session_ids: std::collections::HashSet<String> = self
113            .events
114            .lock()
115            .unwrap()
116            .iter()
117            .filter_map(|e| session_id_of(&e.event))
118            .collect();
119        session_ids
120            .iter()
121            .filter_map(|sid| self.project_session(sid))
122            .collect()
123    }
124
125    pub fn event_count(&self) -> usize {
126        self.events.lock().unwrap().len()
127    }
128
129    pub fn replay(&self) {
130        // Events are already in the log; projection reads them
131        let _ = self.all_events();
132    }
133}
134
135fn session_matches(event: &SessionEvent, session_id: &str) -> bool {
136    session_id_of(event).as_deref() == Some(session_id)
137}
138
139fn session_id_of(event: &SessionEvent) -> Option<String> {
140    match event {
141        SessionEvent::Created { session_id, .. } => Some(session_id.clone()),
142        SessionEvent::CommitmentSubmitted { session_id, .. } => Some(session_id.clone()),
143        SessionEvent::ShareSubmitted { session_id, .. } => Some(session_id.clone()),
144        SessionEvent::Completed { session_id } => Some(session_id.clone()),
145        SessionEvent::Expired { session_id } => Some(session_id.clone()),
146        SessionEvent::Aborted { session_id, .. } => Some(session_id.clone()),
147    }
148}
149
150fn apply_event(proj: &mut SessionProjection, event: &SessionEvent) {
151    match event {
152        SessionEvent::Created {
153            threshold,
154            party_count,
155            ..
156        } => {
157            proj.state = "pending".into();
158            proj.threshold = *threshold;
159            proj.party_count = *party_count;
160        }
161        SessionEvent::CommitmentSubmitted { signer_id, .. } => {
162            proj.commitments.push(signer_id.clone());
163        }
164        SessionEvent::ShareSubmitted { signer_id, .. } => {
165            proj.shares.push(signer_id.clone());
166        }
167        SessionEvent::Completed { .. } => {
168            proj.state = "completed".into();
169        }
170        SessionEvent::Expired { .. } => {
171            proj.state = "expired".into();
172        }
173        SessionEvent::Aborted { .. } => {
174            proj.state = "aborted".into();
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn append_and_read() {
185        let store = EventStore::new();
186        store.append(SessionEvent::Created {
187            session_id: "s1".into(),
188            threshold: 2,
189            party_count: 3,
190        });
191        assert_eq!(store.event_count(), 1);
192    }
193
194    #[test]
195    fn project_session() {
196        let store = EventStore::new();
197        store.append(SessionEvent::Created {
198            session_id: "s1".into(),
199            threshold: 2,
200            party_count: 3,
201        });
202        store.append(SessionEvent::CommitmentSubmitted {
203            session_id: "s1".into(),
204            signer_id: "alice".into(),
205        });
206        store.append(SessionEvent::Completed {
207            session_id: "s1".into(),
208        });
209
210        let proj = store.project_session("s1").unwrap();
211        assert_eq!(proj.state, "completed");
212        assert_eq!(proj.threshold, 2);
213        assert_eq!(proj.commitments, vec!["alice"]);
214    }
215
216    #[test]
217    fn project_all_sessions() {
218        let store = EventStore::new();
219        store.append(SessionEvent::Created {
220            session_id: "s1".into(),
221            threshold: 1,
222            party_count: 1,
223        });
224        store.append(SessionEvent::Created {
225            session_id: "s2".into(),
226            threshold: 2,
227            party_count: 3,
228        });
229        let all = store.project_all();
230        assert_eq!(all.len(), 2);
231    }
232
233    #[test]
234    fn events_for_specific_session() {
235        let store = EventStore::new();
236        store.append(SessionEvent::Created {
237            session_id: "s1".into(),
238            threshold: 2,
239            party_count: 3,
240        });
241        store.append(SessionEvent::Created {
242            session_id: "s2".into(),
243            threshold: 2,
244            party_count: 3,
245        });
246        assert_eq!(store.events_for_session("s1").len(), 1);
247    }
248
249    #[test]
250    fn sequence_numbers_monotonic() {
251        let store = EventStore::new();
252        let s1 = store.append(SessionEvent::Created {
253            session_id: "s1".into(),
254            threshold: 2,
255            party_count: 3,
256        });
257        let s2 = store.append(SessionEvent::Completed {
258            session_id: "s1".into(),
259        });
260        assert!(s1 < s2);
261    }
262
263    #[test]
264    fn project_nonexistent_returns_none() {
265        let store = EventStore::new();
266        assert!(store.project_session("nope").is_none());
267    }
268
269    #[test]
270    fn aborted_state() {
271        let store = EventStore::new();
272        store.append(SessionEvent::Created {
273            session_id: "s1".into(),
274            threshold: 2,
275            party_count: 3,
276        });
277        store.append(SessionEvent::Aborted {
278            session_id: "s1".into(),
279            reason: "test".into(),
280        });
281        let proj = store.project_session("s1").unwrap();
282        assert_eq!(proj.state, "aborted");
283    }
284
285    #[test]
286    fn multiple_commitments_tracked() {
287        let store = EventStore::new();
288        store.append(SessionEvent::Created {
289            session_id: "s1".into(),
290            threshold: 2,
291            party_count: 3,
292        });
293        store.append(SessionEvent::CommitmentSubmitted {
294            session_id: "s1".into(),
295            signer_id: "a".into(),
296        });
297        store.append(SessionEvent::CommitmentSubmitted {
298            session_id: "s1".into(),
299            signer_id: "b".into(),
300        });
301        let proj = store.project_session("s1").unwrap();
302        assert_eq!(proj.commitments.len(), 2);
303    }
304}