substrate_core/event_store_port.rs
1//! EventStorePort — append-only per-aggregate event log with global ordering.
2//!
3//! Core defines the contract and replay/projection helpers; `store-sqlite`
4//! provides the durable SQLite implementation.
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::domain::TaskState;
10
11/// A persisted domain event with monotonic per-aggregate and global sequence.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct EventEnvelope<E> {
14 /// Aggregate this event belongs to.
15 pub aggregate_id: Uuid,
16 /// Monotonic sequence within the aggregate (0-based).
17 pub aggregate_seq: u64,
18 /// Monotonic sequence across all aggregates.
19 pub global_seq: u64,
20 /// Domain event payload.
21 pub event: E,
22 /// Unix epoch seconds when the event was recorded.
23 pub occurred_at: i64,
24}
25
26/// Append-only event log port.
27///
28/// Implementations MUST guarantee:
29/// - events for an aggregate are returned in `aggregate_seq` order;
30/// - `global_seq` is strictly monotonic across all appends;
31/// - duplicate `(aggregate_id, aggregate_seq)` appends are rejected.
32pub trait EventStorePort: Send + Sync {
33 /// Error type returned by event-store operations.
34 type Error: std::error::Error + Send + Sync + 'static;
35 /// Serde-serializable domain event type stored by this port.
36 type Event: Serialize + for<'de> Deserialize<'de> + Clone + Send + Sync;
37
38 /// Append `event` to the log for `aggregate_id`.
39 ///
40 /// `expected_seq` is the sequence number this event will receive (equal to
41 /// the current event count for the aggregate). Mismatch rejects the append.
42 fn append(
43 &self,
44 aggregate_id: Uuid,
45 expected_seq: u64,
46 event: &Self::Event,
47 ) -> Result<EventEnvelope<Self::Event>, Self::Error>;
48
49 /// Load all events for `aggregate_id` in `aggregate_seq` order.
50 fn load(&self, aggregate_id: Uuid) -> Result<Vec<EventEnvelope<Self::Event>>, Self::Error>;
51}
52
53/// Fold an ordered event stream into aggregate state.
54pub trait Projection {
55 /// Rebuilt aggregate state.
56 type State;
57 /// Domain event type consumed by this projection.
58 type Event;
59
60 /// State before any events are applied.
61 fn initial() -> Self::State;
62
63 /// Apply a single event to `state`, returning the new state.
64 fn apply(state: Self::State, event: &Self::Event) -> Self::State;
65}
66
67/// Replay `events` through `P`, returning the folded state.
68pub fn replay<P: Projection>(events: &[P::Event]) -> P::State {
69 events
70 .iter()
71 .fold(P::initial(), |state, event| P::apply(state, event))
72}
73
74// ---------------------------------------------------------------------------
75// Task lifecycle demonstration projection
76// ---------------------------------------------------------------------------
77
78/// Task lifecycle events for event-sourced replay.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(tag = "kind", rename_all = "snake_case")]
81pub enum TaskLifecycleEvent {
82 /// Task created with prompt and working directory.
83 Created {
84 /// Instruction handed to an engine.
85 prompt: String,
86 /// Working directory.
87 cwd: String,
88 },
89 /// Lifecycle transition enforced by the FSM at replay time.
90 Advanced {
91 /// Target lifecycle state.
92 to: TaskState,
93 },
94}
95
96/// State rebuilt from a [`TaskLifecycleEvent`] stream.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct TaskProjectionState {
99 /// Aggregate identity (set on first `Created`).
100 pub id: Option<Uuid>,
101 /// Task prompt.
102 pub prompt: String,
103 /// Working directory.
104 pub cwd: String,
105 /// Current lifecycle state.
106 pub state: TaskState,
107}
108
109/// Projection that folds [`TaskLifecycleEvent`] into [`TaskProjectionState`].
110pub struct TaskLifecycleProjection;
111
112impl Projection for TaskLifecycleProjection {
113 type State = TaskProjectionState;
114 type Event = TaskLifecycleEvent;
115
116 fn initial() -> Self::State {
117 TaskProjectionState {
118 id: None,
119 prompt: String::new(),
120 cwd: String::new(),
121 state: TaskState::Submitted,
122 }
123 }
124
125 fn apply(mut state: Self::State, event: &Self::Event) -> Self::State {
126 match event {
127 TaskLifecycleEvent::Created { prompt, cwd } => {
128 state.prompt = prompt.clone();
129 state.cwd = cwd.clone();
130 state.state = TaskState::Submitted;
131 }
132 TaskLifecycleEvent::Advanced { to } => {
133 if TaskState::can_transition(state.state, *to) {
134 state.state = *to;
135 }
136 }
137 }
138 state
139 }
140}
141
142/// Replay task lifecycle events for `aggregate_id` from `store`.
143pub fn replay_task_state<S>(store: &S, aggregate_id: Uuid) -> Result<TaskProjectionState, S::Error>
144where
145 S: EventStorePort<Event = TaskLifecycleEvent>,
146{
147 let envelopes = store.load(aggregate_id)?;
148 let events: Vec<TaskLifecycleEvent> = envelopes.into_iter().map(|e| e.event).collect();
149 Ok(replay::<TaskLifecycleProjection>(&events))
150}