use sha3::{Digest, Sha3_256};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use ulid::Ulid;
use crate::{cursor::Args, metadata::Metadata, Event, EventFilter, Executor};
pub fn hash_ids(ids: Vec<impl Into<String>>) -> String {
let mut hasher = Sha3_256::new();
for id in ids {
let id: String = id.into();
hasher.update((id.len() as u64).to_be_bytes());
hasher.update(id);
}
hex::encode(hasher.finalize())
}
#[derive(Debug, Error)]
pub enum WriteError {
#[error("invalid original version")]
InvalidOriginalVersion,
#[error("trying to commit event without data")]
MissingData,
#[error("all events in one commit must belong to aggregate type {expected}, got {got}")]
MixedAggregateTypes {
expected: &'static str,
got: &'static str,
},
#[error("aggregate version overflow")]
VersionOverflow,
#[error("{0}")]
Unknown(#[from] anyhow::Error),
#[error("systemtime >> {0}")]
SystemTime(#[from] std::time::SystemTimeError),
}
pub trait Aggregate: Default {
fn aggregate_type() -> &'static str;
}
pub trait AggregateEvent: Aggregate {
fn event_name() -> &'static str;
}
#[derive(Clone)]
pub struct WriteBuilder {
aggregate_id: String,
aggregate_type: &'static str,
routing_key: Option<String>,
routing_key_locked: bool,
original_version: u16,
data: Vec<(&'static str, Vec<u8>)>,
metadata: Metadata,
mixed_types: Option<(&'static str, &'static str)>,
}
impl WriteBuilder {
pub fn new(aggregate_id: impl Into<String>) -> WriteBuilder {
WriteBuilder {
aggregate_id: aggregate_id.into(),
aggregate_type: "",
routing_key: None,
routing_key_locked: false,
original_version: 0,
data: Vec::default(),
metadata: Default::default(),
mixed_types: None,
}
}
pub fn ids(ids: Vec<impl Into<String>>) -> WriteBuilder {
Self::new(hash_ids(ids))
}
pub fn original_version(&mut self, v: u16) -> &mut Self {
self.original_version = v;
self
}
pub fn routing_key(&mut self, v: impl Into<String>) -> &mut Self {
self.routing_key_opt(Some(v.into()))
}
pub fn routing_key_opt(&mut self, v: Option<String>) -> &mut Self {
if !self.routing_key_locked {
self.routing_key = v;
self.routing_key_locked = true;
}
self
}
pub fn metadata<M>(&mut self, key: impl Into<String>, value: &M) -> &mut Self
where
M: bitcode::Encode,
{
self.metadata.insert_enc(key, value);
self
}
pub fn requested_by(&mut self, value: impl Into<String>) -> &mut Self {
self.metadata.set_requested_by(value);
self
}
pub fn requested_as(&mut self, value: impl Into<String>) -> &mut Self {
self.metadata.set_requested_as(value);
self
}
pub fn metadata_from(&mut self, value: impl Into<Metadata>) -> &mut Self {
self.metadata = value.into();
self
}
pub fn event<D>(&mut self, v: &D) -> &mut Self
where
D: AggregateEvent + bitcode::Encode,
{
if self.aggregate_type.is_empty() {
self.aggregate_type = D::aggregate_type();
} else if self.aggregate_type != D::aggregate_type() && self.mixed_types.is_none() {
self.mixed_types = Some((self.aggregate_type, D::aggregate_type()));
}
self.data.push((D::event_name(), bitcode::encode(v)));
self
}
pub async fn commit<E: Executor>(&self, executor: &E) -> Result<String, WriteError> {
if self.data.is_empty() {
return Err(WriteError::MissingData);
}
if let Some((expected, got)) = self.mixed_types {
return Err(WriteError::MixedAggregateTypes { expected, got });
}
let existing_key = if self.original_version == 0 {
None
} else {
executor
.stream_routing_key(self.aggregate_type.to_owned(), self.aggregate_id.to_owned())
.await
.map_err(WriteError::Unknown)?
};
let routing_key = match existing_key {
Some(key) => key,
None => self
.routing_key
.to_owned()
.or_else(|| executor.default_routing_key().map(str::to_owned)),
};
let mut events = vec![];
let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
for (offset, (name, data)) in self.data.iter().enumerate() {
let version = u16::try_from(offset)
.ok()
.and_then(|o| self.original_version.checked_add(1)?.checked_add(o))
.ok_or(WriteError::VersionOverflow)?;
let event = Event {
id: Ulid::generate(),
name: name.to_string(),
data: data.to_vec(),
metadata: self.metadata.clone(),
timestamp: now.as_secs(),
timestamp_subsec: now.subsec_millis(),
aggregate_id: self.aggregate_id.to_owned(),
aggregate_type: self.aggregate_type.to_owned(),
version,
routing_key: routing_key.to_owned(),
};
events.push(event);
}
executor.write(events).await?;
Ok(self.aggregate_id.to_owned())
}
}
pub fn create() -> WriteBuilder {
WriteBuilder::new(Ulid::generate())
}
pub fn append(id: impl Into<String>) -> WriteBuilder {
WriteBuilder::new(id)
}
pub trait AggregateExt<E: Executor> {
fn has_event<A: AggregateEvent>(
&self,
id: impl Into<String>,
) -> impl std::future::Future<Output = anyhow::Result<bool>> + Send;
fn original_version<A: AggregateEvent>(
&self,
id: impl Into<String>,
) -> impl std::future::Future<Output = anyhow::Result<Option<u16>>> + Send;
}
impl<E: Executor> AggregateExt<E> for E {
fn has_event<A: AggregateEvent>(
&self,
id: impl Into<String>,
) -> impl std::future::Future<Output = anyhow::Result<bool>> + Send {
let id = id.into();
Box::pin(async {
let result = self
.read(
Some(Arc::from([EventFilter::exact(
A::aggregate_type(),
id,
A::event_name(),
)])),
None,
Args::backward(1, None),
None,
)
.await?;
Ok(!result.edges.is_empty())
})
}
fn original_version<A: AggregateEvent>(
&self,
id: impl Into<String>,
) -> impl std::future::Future<Output = anyhow::Result<Option<u16>>> + Send {
let id = id.into();
Box::pin(async {
let result = self
.read(
Some(Arc::from([EventFilter::by_id(A::aggregate_type(), id)])),
None,
Args::backward(1, None),
None,
)
.await?;
Ok(result.edges.first().map(|e| e.node.version))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cursor::{Args, ReadResult, Value};
use crate::{EventFilter, RoutingKey};
#[test]
fn hash_ids_is_collision_free_across_boundaries() {
assert_ne!(
hash_ids(vec!["ab", "c"]),
hash_ids(vec!["a", "bc"]),
"id-boundary shifts must produce different digests"
);
assert_eq!(hash_ids(vec!["a", "b"]), hash_ids(vec!["a", "b"]));
}
struct UnreachableExecutor;
#[async_trait::async_trait]
impl Executor for UnreachableExecutor {
async fn write(&self, _events: Vec<Event>) -> Result<(), WriteError> {
unreachable!()
}
async fn get_subscriber_cursor(&self, _key: String) -> anyhow::Result<Option<Value>> {
unreachable!()
}
async fn is_subscriber_running(
&self,
_key: String,
_worker_id: Ulid,
) -> anyhow::Result<bool> {
unreachable!()
}
async fn upsert_subscriber(&self, _key: String, _worker_id: Ulid) -> anyhow::Result<()> {
unreachable!()
}
async fn acknowledge(
&self,
_key: String,
_worker_id: Ulid,
_cursor: Value,
_lag: u64,
) -> anyhow::Result<bool> {
unreachable!()
}
async fn read(
&self,
_aggregators: Option<Arc<[EventFilter]>>,
_routing_key: Option<RoutingKey>,
_args: Args,
_to_micros: Option<u64>,
) -> anyhow::Result<ReadResult<Event>> {
unreachable!()
}
async fn latest_timestamp(
&self,
_aggregators: Option<Arc<[EventFilter]>>,
_routing_key: Option<RoutingKey>,
) -> anyhow::Result<u64> {
unreachable!()
}
async fn get_snapshot(
&self,
_aggregate_type: String,
_aggregate_revision: String,
_id: String,
) -> anyhow::Result<Option<(Vec<u8>, Value)>> {
unreachable!()
}
async fn save_snapshot(
&self,
_aggregate_type: String,
_aggregate_revision: String,
_id: String,
_data: Vec<u8>,
_cursor: Value,
) -> anyhow::Result<()> {
unreachable!()
}
async fn delete_snapshot(
&self,
_aggregate_type: String,
_id: String,
) -> anyhow::Result<()> {
unreachable!()
}
}
#[derive(bitcode::Encode, bitcode::Decode, Default)]
struct AlphaOpened;
impl Aggregate for AlphaOpened {
fn aggregate_type() -> &'static str {
"test/Alpha"
}
}
impl AggregateEvent for AlphaOpened {
fn event_name() -> &'static str {
"AlphaOpened"
}
}
#[derive(bitcode::Encode, bitcode::Decode, Default)]
struct BetaOpened;
impl Aggregate for BetaOpened {
fn aggregate_type() -> &'static str {
"test/Beta"
}
}
impl AggregateEvent for BetaOpened {
fn event_name() -> &'static str {
"BetaOpened"
}
}
#[tokio::test]
async fn commit_rejects_mixed_aggregate_types() {
let result = create()
.event(&AlphaOpened)
.event(&BetaOpened)
.commit(&UnreachableExecutor)
.await;
assert!(matches!(
result,
Err(WriteError::MixedAggregateTypes {
expected: "test/Alpha",
got: "test/Beta",
})
));
}
#[tokio::test]
async fn commit_rejects_empty_builder() {
let result = create().commit(&UnreachableExecutor).await;
assert!(matches!(result, Err(WriteError::MissingData)));
}
}