use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use turnframe_core::case::CaseKey;
use turnframe_core::event::OutboxEntry;
use turnframe_core::ids::{AccountId, CaseRevision, CommandId, EventId, InteractionId, TurnId};
use turnframe_core::interaction::Interaction;
use turnframe_core::replay::{ReplayRecord, TurnPhase};
use crate::error::{StoreError, bundle_account_mismatch, invalid_record};
use crate::events::EventBatch;
use crate::interaction::{InvalidationReason, ResolutionOutcome};
use crate::journal::JournalOutcome;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournalCompletion {
pub command_id: CommandId,
pub outcome: JournalOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionFinish {
pub interaction_id: InteractionId,
pub outcome: ResolutionOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionInsert {
pub interaction: Interaction,
pub replace_blocking: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CaseInvalidation {
pub case_key: CaseKey,
pub new_revision: CaseRevision,
pub reason: InvalidationReason,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnPhaseUpdate {
pub turn_id: TurnId,
pub phase: TurnPhase,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CommitBundle {
#[serde(default)]
pub journal_completions: Vec<JournalCompletion>,
#[serde(default)]
pub events: Vec<EventBatch>,
#[serde(default)]
pub interaction_finishes: Vec<InteractionFinish>,
#[serde(default)]
pub interaction_invalidations: Vec<CaseInvalidation>,
#[serde(default)]
pub interaction_inserts: Vec<InteractionInsert>,
#[serde(default)]
pub outbox_entries: Vec<OutboxEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replay_record: Option<ReplayRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_phase: Option<TurnPhaseUpdate>,
}
impl CommitBundle {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_journal_completion(
mut self,
command_id: CommandId,
outcome: JournalOutcome,
) -> Self {
self.journal_completions.push(JournalCompletion {
command_id,
outcome,
});
self
}
#[must_use]
pub fn with_events(mut self, batch: EventBatch) -> Self {
self.events.push(batch);
self
}
#[must_use]
pub fn with_interaction_finish(
mut self,
interaction_id: InteractionId,
outcome: ResolutionOutcome,
) -> Self {
self.interaction_finishes.push(InteractionFinish {
interaction_id,
outcome,
});
self
}
#[must_use]
pub fn with_invalidation(
mut self,
case_key: CaseKey,
new_revision: CaseRevision,
reason: InvalidationReason,
) -> Self {
self.interaction_invalidations.push(CaseInvalidation {
case_key,
new_revision,
reason,
});
self
}
#[must_use]
pub fn with_interaction_insert(
mut self,
interaction: Interaction,
replace_blocking: bool,
) -> Self {
self.interaction_inserts.push(InteractionInsert {
interaction,
replace_blocking,
});
self
}
#[must_use]
pub fn with_outbox_entry(mut self, entry: OutboxEntry) -> Self {
self.outbox_entries.push(entry);
self
}
#[must_use]
pub fn with_replay_record(mut self, record: ReplayRecord) -> Self {
self.replay_record = Some(record);
self
}
#[must_use]
pub fn with_turn_phase(mut self, turn_id: TurnId, phase: TurnPhase) -> Self {
self.turn_phase = Some(TurnPhaseUpdate { turn_id, phase });
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.journal_completions.is_empty()
&& self.events.is_empty()
&& self.interaction_finishes.is_empty()
&& self.interaction_invalidations.is_empty()
&& self.interaction_inserts.is_empty()
&& self.outbox_entries.is_empty()
&& self.replay_record.is_none()
&& self.turn_phase.is_none()
}
pub fn validate(&self, account: &AccountId) -> Result<(), StoreError> {
for batch in &self.events {
if &batch.account_id != account {
return Err(bundle_account_mismatch());
}
if batch.is_empty() {
return Err(invalid_record());
}
}
if self
.interaction_inserts
.iter()
.any(|insert| &insert.interaction.account_id != account)
{
return Err(bundle_account_mismatch());
}
if self
.replay_record
.as_ref()
.is_some_and(|record| &record.account_id != account)
{
return Err(bundle_account_mismatch());
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommitReceipt {
pub event_ids: Vec<EventId>,
pub inserted_interactions: Vec<InteractionId>,
pub invalidated_interactions: Vec<InteractionId>,
pub committed_at: DateTime<Utc>,
}
#[async_trait]
pub trait CommitStore: Send + Sync {
async fn commit(
&self,
account: &AccountId,
bundle: CommitBundle,
) -> Result<CommitReceipt, StoreError>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{codes, has_code};
use turnframe_core::ids::ConversationId;
#[test]
fn empty_bundle_is_empty_and_valid() {
let bundle = CommitBundle::new();
assert!(bundle.is_empty());
assert!(bundle.validate(&AccountId::from("a")).is_ok());
let with_phase = bundle.with_turn_phase(TurnId::nil(), TurnPhase::Committed);
assert!(!with_phase.is_empty());
}
#[test]
fn validate_refuses_foreign_and_empty_items() {
let account = AccountId::from("a");
let foreign = CommitBundle::new().with_events(EventBatch::new(
AccountId::from("b"),
CaseKey::new("w", "c"),
CommandId::nil(),
CaseRevision(1),
vec![],
));
assert!(has_code(
&foreign.validate(&account).unwrap_err(),
codes::BUNDLE_ACCOUNT_MISMATCH
));
let empty = CommitBundle::new().with_events(EventBatch::new(
account.clone(),
CaseKey::new("w", "c"),
CommandId::nil(),
CaseRevision(1),
vec![],
));
assert!(has_code(
&empty.validate(&account).unwrap_err(),
codes::INVALID_RECORD
));
let replay = CommitBundle::new().with_replay_record(ReplayRecord::received(
TurnId::nil(),
ConversationId::nil(),
AccountId::from("b"),
DateTime::<Utc>::UNIX_EPOCH,
));
assert!(has_code(
&replay.validate(&account).unwrap_err(),
codes::BUNDLE_ACCOUNT_MISMATCH
));
}
}