substrate-core 0.3.10

Hexagonal core contracts (domain + ports) for substrate. Depends on nothing but serde/thiserror/uuid/async-trait.
Documentation
//! Event sourcing: projection replay and task lifecycle fold.

use substrate_core::domain::TaskState;
use substrate_core::event_store_port::{
    replay, EventEnvelope, EventStorePort, TaskLifecycleEvent, TaskLifecycleProjection,
};
use uuid::Uuid;

struct InMemoryEventStore {
    events: std::sync::Mutex<Vec<EventEnvelope<TaskLifecycleEvent>>>,
}

impl InMemoryEventStore {
    fn new() -> Self {
        Self {
            events: std::sync::Mutex::new(Vec::new()),
        }
    }
}

#[derive(Debug)]
struct MemStoreError;

impl std::fmt::Display for MemStoreError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("duplicate seq")
    }
}

impl std::error::Error for MemStoreError {}

impl EventStorePort for InMemoryEventStore {
    type Error = MemStoreError;
    type Event = TaskLifecycleEvent;

    fn append(
        &self,
        aggregate_id: Uuid,
        expected_seq: u64,
        event: &Self::Event,
    ) -> Result<EventEnvelope<Self::Event>, Self::Error> {
        let mut events = self.events.lock().unwrap();
        let current = events
            .iter()
            .filter(|e| e.aggregate_id == aggregate_id)
            .count() as u64;
        if current != expected_seq {
            return Err(MemStoreError);
        }
        let aggregate_seq = expected_seq;
        let global_seq = events.len() as u64;
        let envelope = EventEnvelope {
            aggregate_id,
            aggregate_seq,
            global_seq,
            event: event.clone(),
            occurred_at: 1,
        };
        events.push(envelope.clone());
        Ok(envelope)
    }

    fn load(&self, aggregate_id: Uuid) -> Result<Vec<EventEnvelope<Self::Event>>, Self::Error> {
        let events = self.events.lock().unwrap();
        let mut out: Vec<_> = events
            .iter()
            .filter(|e| e.aggregate_id == aggregate_id)
            .cloned()
            .collect();
        out.sort_by_key(|e| e.aggregate_seq);
        Ok(out)
    }
}

#[test]
fn replay_folds_task_lifecycle_into_final_state() {
    let events = vec![
        TaskLifecycleEvent::Created {
            prompt: "fix bug".into(),
            cwd: "/tmp".into(),
        },
        TaskLifecycleEvent::Advanced {
            to: TaskState::Working,
        },
        TaskLifecycleEvent::Advanced {
            to: TaskState::Completed,
        },
    ];
    let state = replay::<TaskLifecycleProjection>(&events);
    assert_eq!(state.prompt, "fix bug");
    assert_eq!(state.cwd, "/tmp");
    assert_eq!(state.state, TaskState::Completed);
}

#[test]
fn replay_rejects_invalid_fsm_edges() {
    let events = vec![
        TaskLifecycleEvent::Created {
            prompt: "x".into(),
            cwd: ".".into(),
        },
        TaskLifecycleEvent::Advanced {
            to: TaskState::Completed,
        },
    ];
    let state = replay::<TaskLifecycleProjection>(&events);
    assert_eq!(state.state, TaskState::Submitted);
}

#[test]
fn in_memory_append_load_monotonic_seq() {
    let store = InMemoryEventStore::new();
    let id = Uuid::new_v4();
    store
        .append(
            id,
            0,
            &TaskLifecycleEvent::Created {
                prompt: "a".into(),
                cwd: "b".into(),
            },
        )
        .unwrap();
    store
        .append(
            id,
            1,
            &TaskLifecycleEvent::Advanced {
                to: TaskState::Working,
            },
        )
        .unwrap();

    let loaded = store.load(id).unwrap();
    assert_eq!(loaded.len(), 2);
    assert_eq!(loaded[0].aggregate_seq, 0);
    assert_eq!(loaded[1].aggregate_seq, 1);
    assert_eq!(loaded[0].global_seq, 0);
    assert_eq!(loaded[1].global_seq, 1);
}

#[test]
fn duplicate_expected_seq_rejected() {
    let store = InMemoryEventStore::new();
    let id = Uuid::new_v4();
    store
        .append(
            id,
            0,
            &TaskLifecycleEvent::Created {
                prompt: "a".into(),
                cwd: "b".into(),
            },
        )
        .unwrap();
    assert!(store
        .append(
            id,
            0,
            &TaskLifecycleEvent::Advanced {
                to: TaskState::Working,
            },
        )
        .is_err());
}