use serde::{Deserialize, Serialize};
use crate::Room;
pub const DORMANT_AFTER_LAPSE_DAYS: u32 = 30;
pub const DEFAULT_EPOCH_LIFETIME_DAYS: u32 = 365;
const DAY: u64 = 24 * 60 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Lifecycle {
Live,
Lapsed,
Dormant,
Reclaimable,
}
impl Lifecycle {
pub fn accepts_writes(&self) -> bool {
matches!(self, Lifecycle::Live)
}
pub fn serves_reads(&self) -> bool {
true
}
pub fn admits_a_claim(&self) -> bool {
matches!(self, Lifecycle::Dormant | Lifecycle::Reclaimable)
}
pub fn as_str(&self) -> &'static str {
match self {
Lifecycle::Live => "live",
Lifecycle::Lapsed => "lapsed",
Lifecycle::Dormant => "dormant",
Lifecycle::Reclaimable => "reclaimable",
}
}
}
impl Room {
pub fn lifecycle(&self, now: u64) -> Lifecycle {
let Some(expires) = self.epoch_expires_at else {
return Lifecycle::Live;
};
if now < expires {
return Lifecycle::Live;
}
let lapsed_for = now - expires;
let grace = u64::from(DORMANT_AFTER_LAPSE_DAYS) * DAY;
if lapsed_for < grace {
return Lifecycle::Lapsed;
}
let reclaim_after = std::cmp::max(u64::from(self.retention_days) * DAY, grace);
if lapsed_for < reclaim_after {
return Lifecycle::Dormant;
}
Lifecycle::Reclaimable
}
pub fn expires_at(&self) -> Option<u64> {
self.epoch_expires_at
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Visibility;
const NOW: u64 = 1_800_000_000;
fn room(expires_at: Option<u64>, retention_days: u32) -> Room {
Room {
room_id: "did:key:zRoom".into(),
owner_did: "did:key:zOwner".into(),
visibility: Visibility::Open,
retention_policy: crate::RetentionPolicy::Chained,
anchor_cadence: Default::default(),
epoch: 1,
next_version: 1,
retention_days,
epoch_expires_at: expires_at,
created_at: 0,
updated_at: 0,
mirror_of: None,
}
}
#[test]
fn a_room_inside_its_epoch_is_live() {
assert_eq!(room(Some(NOW + DAY), 90).lifecycle(NOW), Lifecycle::Live);
}
#[test]
fn the_states_follow_the_two_clocks() {
let r = room(Some(NOW), 90);
assert_eq!(r.lifecycle(NOW), Lifecycle::Lapsed, "the moment it expires");
assert_eq!(
r.lifecycle(NOW + 29 * DAY),
Lifecycle::Lapsed,
"still inside the grace window"
);
assert_eq!(
r.lifecycle(NOW + 31 * DAY),
Lifecycle::Dormant,
"past the grace window, inside retention"
);
assert_eq!(
r.lifecycle(NOW + 91 * DAY),
Lifecycle::Reclaimable,
"past the retention stated at creation"
);
}
#[test]
fn retention_runs_from_the_lapse_not_from_dormancy() {
let r = room(Some(NOW), 90);
assert_eq!(r.lifecycle(NOW + 90 * DAY), Lifecycle::Reclaimable);
assert_ne!(
r.lifecycle(NOW + 90 * DAY),
Lifecycle::Dormant,
"90 days of retention must not become 120"
);
}
#[test]
fn a_short_retention_never_reclaims_before_the_notice() {
let r = room(Some(NOW), 7);
assert_eq!(r.lifecycle(NOW + DAY), Lifecycle::Lapsed);
assert_eq!(
r.lifecycle(NOW + 8 * DAY),
Lifecycle::Lapsed,
"past its stated retention, but the owner has not been told yet"
);
assert_eq!(
r.lifecycle(NOW + 31 * DAY),
Lifecycle::Reclaimable,
"reclaimable at the notice, with no dormant period"
);
}
#[test]
fn a_room_with_no_expiry_never_lapses() {
let r = room(None, 1);
assert_eq!(r.lifecycle(NOW), Lifecycle::Live);
assert_eq!(r.lifecycle(NOW + 10_000 * DAY), Lifecycle::Live);
}
#[test]
fn every_state_still_serves_reads() {
for s in [
Lifecycle::Live,
Lifecycle::Lapsed,
Lifecycle::Dormant,
Lifecycle::Reclaimable,
] {
assert!(s.serves_reads(), "{s:?} must still serve reads");
}
}
#[test]
fn a_claim_needs_dormancy_not_merely_a_lapse() {
let r = room(Some(NOW), 90);
assert!(
!r.lifecycle(NOW + DAY).admits_a_claim(),
"an epoch expiring is often somebody on holiday, not an abandoned room"
);
assert!(
!r.lifecycle(NOW + 29 * DAY).admits_a_claim(),
"still inside the grace window"
);
assert!(
r.lifecycle(NOW + 31 * DAY).admits_a_claim(),
"past the grace window, and the owner has had their notice"
);
assert!(
r.lifecycle(NOW + 91 * DAY).admits_a_claim(),
"a room past retention is more claimable, not less — the only party who could \
save it must not lose the right exactly when it matters"
);
}
#[test]
fn a_live_room_is_never_claimable() {
assert!(!room(Some(NOW + DAY), 90).lifecycle(NOW).admits_a_claim());
assert!(
!room(None, 90)
.lifecycle(NOW + 10_000 * DAY)
.admits_a_claim(),
"a room that never lapses is never claimable"
);
}
#[test]
fn only_a_live_room_accepts_writes() {
assert!(Lifecycle::Live.accepts_writes());
for s in [
Lifecycle::Lapsed,
Lifecycle::Dormant,
Lifecycle::Reclaimable,
] {
assert!(!s.accepts_writes(), "{s:?} must be read-only");
}
}
}