use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ValidityWindow {
pub valid_from: Option<DateTime<Utc>>,
pub valid_until: Option<DateTime<Utc>>,
}
impl ValidityWindow {
pub const UNBOUNDED: Self = Self {
valid_from: None,
valid_until: None,
};
pub fn state_at(&self, now: DateTime<Utc>) -> InForce {
if let Some(from) = self.valid_from
&& now < from
{
return InForce::NotYetValid { valid_from: from };
}
if let Some(until) = self.valid_until
&& now >= until
{
return InForce::Expired { valid_until: until };
}
InForce::Yes
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", tag = "event")]
pub enum LifecycleEventKind {
Suspended {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Restored {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Superseded { by: String },
Withdrawn {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
}
impl LifecycleEventKind {
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Superseded { .. } | Self::Withdrawn { .. })
}
pub fn verb(&self) -> &'static str {
match self {
Self::Suspended { .. } => "suspended",
Self::Restored { .. } => "restored",
Self::Superseded { .. } => "superseded",
Self::Withdrawn { .. } => "withdrawn",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct LifecycleEvent {
#[serde(flatten)]
pub kind: LifecycleEventKind,
pub recorded_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(transparent)]
pub struct LifecycleLog(Vec<LifecycleEvent>);
impl LifecycleLog {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn events(&self) -> &[LifecycleEvent] {
&self.0
}
pub fn standing_at(&self, now: DateTime<Utc>) -> Option<&LifecycleEvent> {
self.0.iter().rev().find(|e| e.recorded_at <= now)
}
fn latest(&self) -> Option<&LifecycleEvent> {
self.0.last()
}
pub fn is_terminal(&self) -> bool {
self.latest().is_some_and(|e| e.kind.is_terminal())
}
pub fn record(
&mut self,
kind: LifecycleEventKind,
now: DateTime<Utc>,
) -> Result<&LifecycleEvent, AppError> {
if let Some(last) = self.latest() {
if now < last.recorded_at {
return Err(AppError::Conflict(format!(
"lifecycle event recorded at {now} predates the last recorded \
event at {} — the log is append-only and ordered by record time",
last.recorded_at
)));
}
if last.kind.is_terminal() {
return Err(AppError::Conflict(format!(
"artifact was {} at {} and that is terminal — give effect back \
by issuing a fresh artifact, not by reversing the record",
last.kind.verb(),
last.recorded_at
)));
}
}
let standing_is_suspension = matches!(
self.latest().map(|e| &e.kind),
Some(LifecycleEventKind::Suspended { .. })
);
match &kind {
LifecycleEventKind::Suspended { .. } if standing_is_suspension => {
return Err(AppError::Conflict(
"artifact is already suspended; a second suspension records \
nothing a reader could act on"
.into(),
));
}
LifecycleEventKind::Restored { .. } if !standing_is_suspension => {
return Err(AppError::Conflict(
"restoration reverses a suspension, and this artifact is not \
suspended — an artifact that is merely expired is restored by \
re-issuance, not by a lifecycle event"
.into(),
));
}
_ => {}
}
self.0.push(LifecycleEvent {
kind,
recorded_at: now,
});
Ok(self.0.last().expect("just pushed"))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum InForce {
Yes,
#[serde(rename_all = "camelCase")]
NotYetValid { valid_from: DateTime<Utc> },
#[serde(rename_all = "camelCase")]
Expired { valid_until: DateTime<Utc> },
Suspended { since: DateTime<Utc> },
Superseded { at: DateTime<Utc>, by: String },
Withdrawn { at: DateTime<Utc> },
Indeterminate { reason: String },
}
impl InForce {
pub fn is_in_force(&self) -> bool {
matches!(self, Self::Yes)
}
}
pub fn resolve(window: &ValidityWindow, log: &LifecycleLog, now: DateTime<Utc>) -> InForce {
if let Some(event) = log.standing_at(now) {
match &event.kind {
LifecycleEventKind::Suspended { .. } => {
return InForce::Suspended {
since: event.recorded_at,
};
}
LifecycleEventKind::Superseded { by } => {
return InForce::Superseded {
at: event.recorded_at,
by: by.clone(),
};
}
LifecycleEventKind::Withdrawn { .. } => {
return InForce::Withdrawn {
at: event.recorded_at,
};
}
LifecycleEventKind::Restored { .. } => {}
}
}
window.state_at(now)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn window(from_days_ago: i64, until_days_ahead: Option<i64>) -> ValidityWindow {
let now = Utc::now();
ValidityWindow {
valid_from: Some(now - Duration::days(from_days_ago)),
valid_until: until_days_ahead.map(|d| now + Duration::days(d)),
}
}
fn suspended() -> LifecycleEventKind {
LifecycleEventKind::Suspended { reason: None }
}
fn restored() -> LifecycleEventKind {
LifecycleEventKind::Restored { reason: None }
}
#[test]
fn a_later_event_beats_an_artifact_that_is_still_inside_its_window() {
let now = Utc::now();
let w = window(30, Some(365));
assert_eq!(
w.state_at(now),
InForce::Yes,
"precondition: window is open"
);
let mut log = LifecycleLog::default();
log.record(suspended(), now).unwrap();
assert!(matches!(resolve(&w, &log, now), InForce::Suspended { .. }));
}
#[test]
fn no_event_can_put_an_artifact_back_inside_a_window_that_has_closed() {
let now = Utc::now();
let w = ValidityWindow {
valid_from: Some(now - Duration::days(30)),
valid_until: Some(now - Duration::days(1)),
};
let mut log = LifecycleLog::default();
log.record(suspended(), now - Duration::days(10)).unwrap();
log.record(restored(), now).unwrap();
match resolve(&w, &log, now) {
InForce::Expired { .. } => {}
other => panic!("restoration must not extend a closed window: {other:?}"),
}
}
#[test]
fn restoration_inside_an_open_window_returns_the_artifact_to_force() {
let now = Utc::now();
let w = window(30, Some(365));
let mut log = LifecycleLog::default();
log.record(suspended(), now - Duration::days(2)).unwrap();
assert!(!resolve(&w, &log, now).is_in_force());
log.record(restored(), now - Duration::days(1)).unwrap();
assert_eq!(resolve(&w, &log, now), InForce::Yes);
}
#[test]
fn an_empty_log_defers_entirely_to_the_window() {
let now = Utc::now();
let empty = LifecycleLog::default();
assert_eq!(resolve(&window(30, Some(1)), &empty, now), InForce::Yes);
assert!(matches!(
resolve(&window(30, Some(-1)), &empty, now),
InForce::Expired { .. }
));
assert!(matches!(
resolve(&window(-1, Some(30)), &empty, now),
InForce::NotYetValid { .. }
));
}
#[test]
fn an_unbounded_artifact_is_governed_only_by_its_log() {
let now = Utc::now();
let mut log = LifecycleLog::default();
assert_eq!(resolve(&ValidityWindow::UNBOUNDED, &log, now), InForce::Yes);
log.record(LifecycleEventKind::Withdrawn { reason: None }, now)
.unwrap();
assert!(matches!(
resolve(&ValidityWindow::UNBOUNDED, &log, now),
InForce::Withdrawn { .. }
));
}
#[test]
fn window_boundaries_are_valid_from_inclusive_and_valid_until_exclusive() {
let now = Utc::now();
let w = ValidityWindow {
valid_from: Some(now),
valid_until: Some(now + Duration::days(1)),
};
assert_eq!(w.state_at(now), InForce::Yes, "validFrom == now is inside");
let ends_now = ValidityWindow {
valid_from: Some(now - Duration::days(1)),
valid_until: Some(now),
};
assert!(
matches!(ends_now.state_at(now), InForce::Expired { .. }),
"validUntil == now is outside"
);
}
#[test]
fn an_event_does_not_apply_before_it_was_recorded() {
let now = Utc::now();
let w = window(30, Some(365));
let mut log = LifecycleLog::default();
log.record(suspended(), now).unwrap();
assert_eq!(
resolve(&w, &log, now - Duration::hours(1)),
InForce::Yes,
"an hour before the suspension was recorded, the edge was in force"
);
assert!(!resolve(&w, &log, now).is_in_force());
}
#[test]
fn the_standing_event_is_the_latest_one_recorded_by_that_instant() {
let now = Utc::now();
let w = window(30, Some(365));
let mut log = LifecycleLog::default();
log.record(suspended(), now - Duration::days(3)).unwrap();
log.record(restored(), now - Duration::days(1)).unwrap();
assert!(matches!(
resolve(&w, &log, now - Duration::days(2)),
InForce::Suspended { .. }
));
assert_eq!(resolve(&w, &log, now), InForce::Yes);
}
#[test]
fn a_backwards_timestamp_is_refused() {
let now = Utc::now();
let mut log = LifecycleLog::default();
log.record(suspended(), now).unwrap();
let err = log
.record(restored(), now - Duration::days(1))
.expect_err("the log is ordered by record time");
assert!(format!("{err:?}").contains("append-only"), "{err:?}");
}
#[test]
fn a_withdrawal_is_terminal() {
let now = Utc::now();
let mut log = LifecycleLog::default();
log.record(LifecycleEventKind::Withdrawn { reason: None }, now)
.unwrap();
for attempt in [
suspended(),
restored(),
LifecycleEventKind::Superseded { by: "zX".into() },
LifecycleEventKind::Withdrawn { reason: None },
] {
let verb = attempt.verb();
assert!(
log.record(attempt, now).is_err(),
"`{verb}` after a withdrawal must be refused"
);
}
assert_eq!(log.events().len(), 1, "no refused event may have landed");
}
#[test]
fn a_supersession_is_terminal() {
let now = Utc::now();
let mut log = LifecycleLog::default();
log.record(LifecycleEventKind::Superseded { by: "zNew".into() }, now)
.unwrap();
let err = log
.record(restored(), now)
.expect_err("a superseded artifact is replaced, not restorable");
assert!(format!("{err:?}").contains("terminal"), "{err:?}");
assert!(log.is_terminal());
}
#[test]
fn restoration_requires_a_standing_suspension() {
let now = Utc::now();
let mut fresh = LifecycleLog::default();
let err = fresh
.record(restored(), now)
.expect_err("nothing to restore");
assert!(
format!("{err:?}").contains("reverses a suspension"),
"{err:?}"
);
let mut reinstated = LifecycleLog::default();
reinstated.record(suspended(), now).unwrap();
reinstated.record(restored(), now).unwrap();
let err = reinstated
.record(restored(), now)
.expect_err("already restored");
assert!(
format!("{err:?}").contains("reverses a suspension"),
"{err:?}"
);
}
#[test]
fn a_second_suspension_is_refused() {
let now = Utc::now();
let mut log = LifecycleLog::default();
log.record(suspended(), now).unwrap();
let err = log.record(suspended(), now).expect_err("already suspended");
assert!(format!("{err:?}").contains("already suspended"), "{err:?}");
}
#[test]
fn a_suspended_artifact_can_still_be_withdrawn_or_superseded() {
let now = Utc::now();
for terminal in [
LifecycleEventKind::Withdrawn { reason: None },
LifecycleEventKind::Superseded { by: "zNew".into() },
] {
let mut log = LifecycleLog::default();
log.record(suspended(), now).unwrap();
log.record(terminal, now)
.expect("a suspension does not block a terminal event");
assert!(log.is_terminal());
}
}
#[test]
fn an_absent_log_decodes_as_an_empty_one() {
let log: LifecycleLog =
serde_json::from_value(serde_json::json!([])).expect("empty array decodes");
assert!(log.is_empty());
assert_eq!(
serde_json::to_value(LifecycleLog::default()).unwrap(),
serde_json::json!([])
);
}
#[test]
fn events_serialise_camel_case_with_the_verb_as_discriminator() {
let now = Utc::now();
let mut log = LifecycleLog::default();
log.record(
LifecycleEventKind::Suspended {
reason: Some("under moderation".into()),
},
now,
)
.unwrap();
let v = serde_json::to_value(&log).unwrap();
assert_eq!(v[0]["event"], "suspended");
assert_eq!(v[0]["reason"], "under moderation");
assert!(v[0]["recordedAt"].is_string());
let round_tripped: LifecycleLog = serde_json::from_value(v).unwrap();
assert_eq!(round_tripped, log);
}
#[test]
fn resolved_state_serialises_with_a_readable_discriminator() {
let now = Utc::now();
let v = serde_json::to_value(InForce::Superseded {
at: now,
by: "zNew".into(),
})
.unwrap();
assert_eq!(v["state"], "superseded");
assert_eq!(v["by"], "zNew");
assert_eq!(serde_json::to_value(InForce::Yes).unwrap()["state"], "yes");
}
#[test]
fn only_yes_is_in_force() {
let now = Utc::now();
assert!(InForce::Yes.is_in_force());
for state in [
InForce::NotYetValid { valid_from: now },
InForce::Expired { valid_until: now },
InForce::Suspended { since: now },
InForce::Superseded {
at: now,
by: "zX".into(),
},
InForce::Withdrawn { at: now },
InForce::Indeterminate {
reason: "unreadable".into(),
},
] {
assert!(!state.is_in_force(), "{state:?} must not be in force");
}
}
}