use std::sync::Arc;
use serde::{Deserialize, Serialize};
use super::event::{EventId, StoredEvent};
use super::store::{SessionMeta, SessionStatus};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionPolicy {
#[serde(default)]
pub max_age_seconds: Option<u64>,
#[serde(default)]
pub min_age_before_archive_seconds: Option<u64>,
#[serde(default = "RetentionPolicy::default_grace")]
pub grace_seconds: u64,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
max_age_seconds: None,
min_age_before_archive_seconds: None,
grace_seconds: Self::default_grace(),
}
}
}
impl RetentionPolicy {
pub const fn default_grace() -> u64 {
24 * 60 * 60
}
pub fn should_soft_delete(&self, session: &SessionMeta, now_ms: i64) -> bool {
if !matches!(session.status, SessionStatus::Open | SessionStatus::Closed) {
return false;
}
let age_ms = now_ms.saturating_sub(session.created_at_ms);
if let Some(max_age_seconds) = self.max_age_seconds {
if age_ms as u64 >= max_age_seconds.saturating_mul(1_000) {
return true;
}
}
if let Some(min_age) = self.min_age_before_archive_seconds {
if matches!(session.status, SessionStatus::Closed)
&& age_ms as u64 >= min_age.saturating_mul(1_000)
{
return true;
}
}
false
}
pub fn should_hard_delete(&self, session: &SessionMeta, now_ms: i64) -> bool {
if !matches!(session.status, SessionStatus::SoftDeleted) {
return false;
}
let Some(soft_deleted_at) = session.soft_deleted_at_ms else {
return false;
};
let elapsed_ms = now_ms.saturating_sub(soft_deleted_at);
elapsed_ms as u64 >= self.grace_seconds.saturating_mul(1_000)
}
pub fn should_archive(&self, session: &SessionMeta, now_ms: i64) -> bool {
if !matches!(session.status, SessionStatus::Closed) {
return false;
}
let Some(min_age) = self.min_age_before_archive_seconds else {
return false;
};
let age_ms = now_ms.saturating_sub(session.created_at_ms);
age_ms as u64 >= min_age.saturating_mul(1_000)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tombstone {
pub session_id: String,
pub tenant_id: Option<String>,
pub deleted_at_ms: i64,
pub deleted_at: String,
pub final_chain_root_hash: Option<String>,
pub final_event_id: Option<EventId>,
}
#[async_trait::async_trait]
pub trait ArchiveSink: Send + Sync {
async fn archive(
&self,
session: &SessionMeta,
events: &[StoredEvent],
) -> Result<(), super::store::StoreError>;
async fn tombstone(&self, tombstone: &Tombstone) -> Result<(), super::store::StoreError> {
let _ = tombstone;
Ok(())
}
}
pub type SharedArchiveSink = Arc<dyn ArchiveSink>;
#[cfg(test)]
mod tests {
use super::*;
fn meta(
status: SessionStatus,
created_at_ms: i64,
soft_deleted_at_ms: Option<i64>,
) -> SessionMeta {
SessionMeta {
id: "s".into(),
tenant_id: None,
persona: None,
parent_session_id: None,
created_at_ms,
created_at: String::new(),
updated_at_ms: created_at_ms,
updated_at: String::new(),
status,
event_count: 0,
last_event_id: None,
chain_root_hash: None,
closed_at_ms: None,
closed_at: None,
soft_deleted_at_ms,
ttl_seconds: None,
tags: Vec::new(),
attributes: Default::default(),
}
}
#[test]
fn soft_delete_triggers_when_open_session_exceeds_max_age() {
let policy = RetentionPolicy {
max_age_seconds: Some(60),
..RetentionPolicy::default()
};
let session = meta(SessionStatus::Open, 0, None);
assert!(policy.should_soft_delete(&session, 61_000));
assert!(!policy.should_soft_delete(&session, 59_000));
}
#[test]
fn hard_delete_waits_for_grace_window() {
let policy = RetentionPolicy {
grace_seconds: 10,
..RetentionPolicy::default()
};
let session = meta(SessionStatus::SoftDeleted, 0, Some(100));
assert!(!policy.should_hard_delete(&session, 100 + 9_000));
assert!(policy.should_hard_delete(&session, 100 + 10_000));
}
#[test]
fn closed_session_archives_after_min_age() {
let policy = RetentionPolicy {
min_age_before_archive_seconds: Some(5),
..RetentionPolicy::default()
};
let session = meta(SessionStatus::Closed, 0, None);
assert!(!policy.should_soft_delete(&session, 4_000));
assert!(policy.should_soft_delete(&session, 5_000));
assert!(!policy.should_archive(&session, 4_000));
assert!(policy.should_archive(&session, 5_000));
}
#[test]
fn should_archive_only_applies_to_closed_sessions() {
let policy = RetentionPolicy {
min_age_before_archive_seconds: Some(1),
..RetentionPolicy::default()
};
let open = meta(SessionStatus::Open, 0, None);
let closed = meta(SessionStatus::Closed, 0, None);
let soft = meta(SessionStatus::SoftDeleted, 0, Some(0));
assert!(!policy.should_archive(&open, 60_000));
assert!(policy.should_archive(&closed, 60_000));
assert!(!policy.should_archive(&soft, 60_000));
}
}