use crate::access_control::{AccessLevel, RoleAccessMapping, RoleStatus};
use crate::error::SkiljRejection;
use crate::event_store::{BoundedContext, BoundedContextStatus, Event, EventType};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("a projection may only consume EventTypes from its own bounded context")]
ConsumedEventTypeNotInBoundedContext,
#[error("no pending ProjectionRebuild is staged for this projection")]
NoProjectionRebuildStaged,
#[error(
"the projection did not catch up to the requested sequence within the configured wait"
)]
ProjectionCaughtUpTimedOut,
}
impl SkiljRejection for Error {
fn code(&self) -> &str {
match self {
Error::ConsumedEventTypeNotInBoundedContext => {
"consumed_event_type_not_in_bounded_context"
}
Error::NoProjectionRebuildStaged => "no_projection_rebuild_staged",
Error::ProjectionCaughtUpTimedOut => "projection_caught_up_timed_out",
}
}
fn message(&self) -> String {
self.to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Projection {
pub bounded_context: BoundedContext,
pub name: String,
pub schema: String,
pub schema_version: i64,
pub consumed_event_types: Vec<EventType>,
pub sync: bool,
pub caught_up_to: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectionRebuildStatus {
Pending,
Building,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionRebuild {
pub projection: Projection,
pub schema: String,
pub schema_version: i64,
pub consumed_event_types: Vec<EventType>,
pub sync: bool,
pub caught_up_to: Option<i64>,
pub status: ProjectionRebuildStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectionRegistration {
Created {
projection: Projection,
needs_history_fold: bool,
},
RebuildStaged(ProjectionRebuild),
ReconciledTrivially(Projection),
}
#[allow(clippy::too_many_arguments)]
pub fn register_projection(
access_mapping: &RoleAccessMapping,
bounded_context: &BoundedContext,
name: String,
schema: String,
consumed_event_types: Vec<EventType>,
sync: bool,
existing: Option<&Projection>,
staged: Option<&ProjectionRebuild>,
bounded_context_events: &[Event],
) -> crate::error::Result<ProjectionRegistration> {
if access_mapping.status != RoleStatus::Active {
return Err(crate::access_control::Error::GrantNotActive.into());
}
if access_mapping.level != AccessLevel::Admin {
return Err(crate::access_control::Error::InsufficientAccessLevel.into());
}
if &access_mapping.bounded_context != bounded_context {
return Err(crate::access_control::Error::GrantBoundedContextMismatch.into());
}
if bounded_context.status != BoundedContextStatus::Active {
return Err(crate::event_store::Error::BoundedContextArchived.into());
}
if !crate::event_store::valid_schema(&schema) {
return Err(crate::event_store::Error::InvalidSchema.into());
}
if !consumed_event_types
.iter()
.all(|et| &et.bounded_context == bounded_context)
{
return Err(Error::ConsumedEventTypeNotInBoundedContext.into());
}
let Some(existing) = existing else {
let needs_history_fold = sync
&& consumed_event_types
.iter()
.any(|et| bounded_context_events.iter().any(|e| &e.event_type == et));
return Ok(ProjectionRegistration::Created {
projection: Projection {
bounded_context: bounded_context.clone(),
name,
schema,
schema_version: 1,
consumed_event_types,
sync,
caught_up_to: None,
},
needs_history_fold,
});
};
if !crate::event_store::schema_is_backwards_compatible(&existing.schema, &schema) {
return Err(crate::event_store::Error::SchemaIncompatible.into());
}
let schema_changed = existing.schema != schema;
let changed_event_types = existing
.consumed_event_types
.iter()
.filter(|et| !consumed_event_types.contains(et))
.chain(
consumed_event_types
.iter()
.filter(|et| !existing.consumed_event_types.contains(et)),
);
let consumed_change_has_history = changed_event_types
.into_iter()
.any(|et| bounded_context_events.iter().any(|e| &e.event_type == et));
let becoming_sync = !existing.sync && sync;
let rebuild_needed = schema_changed || consumed_change_has_history || becoming_sync;
let new_schema_version = if schema_changed {
existing.schema_version + 1
} else {
existing.schema_version
};
if !rebuild_needed {
return Ok(ProjectionRegistration::ReconciledTrivially(Projection {
consumed_event_types,
sync,
..existing.clone()
}));
}
Ok(ProjectionRegistration::RebuildStaged(match staged {
Some(staged) => ProjectionRebuild {
schema,
schema_version: new_schema_version,
consumed_event_types,
sync,
caught_up_to: None,
..staged.clone()
},
None => ProjectionRebuild {
projection: existing.clone(),
schema,
schema_version: new_schema_version,
consumed_event_types,
sync,
caught_up_to: None,
status: ProjectionRebuildStatus::Pending,
},
}))
}
pub fn rebuild_projection(
access_mapping: &RoleAccessMapping,
projection: &Projection,
staged: Option<&ProjectionRebuild>,
) -> crate::error::Result<ProjectionRebuild> {
if access_mapping.status != RoleStatus::Active {
return Err(crate::access_control::Error::GrantNotActive.into());
}
if access_mapping.level != AccessLevel::Admin {
return Err(crate::access_control::Error::InsufficientAccessLevel.into());
}
if access_mapping.bounded_context != projection.bounded_context {
return Err(crate::access_control::Error::GrantBoundedContextMismatch.into());
}
let Some(staged) = staged else {
return Err(Error::NoProjectionRebuildStaged.into());
};
Ok(ProjectionRebuild {
status: ProjectionRebuildStatus::Building,
..staged.clone()
})
}
pub fn discard_projection_rebuild(
access_mapping: &RoleAccessMapping,
projection: &Projection,
staged: Option<&ProjectionRebuild>,
) -> crate::error::Result<ProjectionRebuild> {
if access_mapping.status != RoleStatus::Active {
return Err(crate::access_control::Error::GrantNotActive.into());
}
if access_mapping.level != AccessLevel::Admin {
return Err(crate::access_control::Error::InsufficientAccessLevel.into());
}
if access_mapping.bounded_context != projection.bounded_context {
return Err(crate::access_control::Error::GrantBoundedContextMismatch.into());
}
let Some(staged) = staged else {
return Err(Error::NoProjectionRebuildStaged.into());
};
Ok(staged.clone())
}
pub fn read_projection(state_json: &str, data_keys: &[crate::encryption::DataKey]) -> String {
if data_keys.is_empty() {
return state_json.to_string();
}
let Ok(mut value) = serde_json::from_str(state_json) else {
return state_json.to_string();
};
crate::encryption::decrypt_ciphertext_leaves(&mut value, data_keys);
serde_json::to_string(&value).expect("re-serialising a parsed JSON Value is infallible")
}
pub struct ProjectionAccessScope<'a> {
pub declares_owner: bool,
pub instance_owner: Option<&'a str>,
pub team_only: Option<&'a str>,
}
pub fn query_projection(
access_mapping: &RoleAccessMapping,
projection: &Projection,
_key: &str,
wait_for_sequence: Option<i64>,
caught_up: bool,
access: ProjectionAccessScope<'_>,
read_projection_result: String,
) -> crate::error::Result<String> {
if access_mapping.status != RoleStatus::Active {
return Err(crate::access_control::Error::GrantNotActive.into());
}
if access_mapping.bounded_context != projection.bounded_context {
return Err(crate::access_control::Error::GrantBoundedContextMismatch.into());
}
if access.declares_owner {
if let Some(scope) = &access_mapping.scope {
if access.instance_owner != Some(scope.as_str()) {
return Err(crate::access_control::Error::GrantScopeMismatch.into());
}
}
}
if !crate::access_control::role_matches_required_team(&access_mapping.role, access.team_only) {
return Err(crate::access_control::Error::NotOnRequiredTeam.into());
}
if wait_for_sequence.is_some() && !caught_up {
return Err(Error::ProjectionCaughtUpTimedOut.into());
}
Ok(read_projection_result)
}