//! Single entry point for pipeline state transitions. //! //! [`apply_transition`] is the **only** function that should mutate a story's //! pipeline stage. It reads the current typed stage from the CRDT, validates //! the transition via the pure [`super::transition()`] function, writes the new //! stage back to the CRDT, and returns a [`TransitionFired`] event for //! downstream subscribers. use super::{PipelineEvent, StoryId, TransitionFired, read_typed, transition}; use chrono::Utc; /// Error type for [`apply_transition`]. #[derive(Debug)] pub enum ApplyError { /// The story was not found in the CRDT. NotFound(String), /// The CRDT projection failed. Projection(super::ProjectionError), /// The transition was invalid for the current stage. InvalidTransition(super::TransitionError), } impl std::fmt::Display for ApplyError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NotFound(id) => write!(f, "story '{id}' not found in CRDT"), Self::Projection(e) => write!(f, "projection error: {e}"), Self::InvalidTransition(e) => write!(f, "{e}"), } } } impl std::error::Error for ApplyError {} impl From for ApplyError { fn from(e: super::ProjectionError) -> Self { Self::Projection(e) } } impl From for ApplyError { fn from(e: super::TransitionError) -> Self { Self::InvalidTransition(e) } } /// Apply a pipeline event to a story, validating the transition and writing /// the new stage to the CRDT. /// /// This is the single canonical entry point for all pipeline stage mutations. /// Returns a [`TransitionFired`] describing what happened, suitable for /// broadcasting to event-bus subscribers and CRDT log consumers. /// /// An optional `content_transform` allows callers to modify the stored content /// (e.g. clearing front-matter fields) atomically with the stage change. pub fn apply_transition( story_id: &str, event: PipelineEvent, content_transform: Option<&dyn Fn(&str) -> String>, ) -> Result { let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?; let before = item.stage.clone(); let after = transition(before.clone(), event.clone())?; let new_dir = after.dir_name(); // Write the new stage to the CRDT (with optional content transform). crate::db::move_item_stage(story_id, new_dir, content_transform); // Write stage-specific metadata into the shared `resume_to` register. // Story 984: Superseded and Rejected stages reuse `resume_to` to carry // their metadata (superseded_by ID and rejection reason respectively), // since these stages never have a resume target. match &after { super::Stage::Superseded { superseded_by, .. } => { crate::crdt_state::set_resume_to_raw(story_id, &superseded_by.0); } super::Stage::Rejected { reason, .. } | super::Stage::Blocked { reason } => { crate::crdt_state::set_resume_to_raw(story_id, reason); } // Story 1105: write the resume target so read-back can reconstruct the // correct variant. Without this, the register is stale (or empty) and // the deserialiser falls back to Coding regardless of where the story // was when it was frozen. super::Stage::Frozen { resume_to } => { crate::crdt_state::set_resume_to_raw(story_id, resume_to.dir_name()); } super::Stage::ReviewHold { resume_to, .. } => { crate::crdt_state::set_resume_to_raw(story_id, resume_to.dir_name()); } _ => {} } let fired = TransitionFired { story_id: StoryId(story_id.to_string()), before, after, event, at: Utc::now(), }; super::events::try_broadcast(&fired); Ok(fired) } /// Convenience: apply a transition, returning an `Err(String)` on failure /// (matches the existing lifecycle function signatures). pub fn apply_transition_str( story_id: &str, event: PipelineEvent, content_transform: Option<&dyn Fn(&str) -> String>, ) -> Result { apply_transition(story_id, event, content_transform).map_err(|e| e.to_string()) } /// Freeze a story. /// /// Story 945: `Stage::Frozen { resume_to }` is the single source of truth; /// the previous `frozen: bool` flag has been removed. Transitions any /// non-terminal stage to `Stage::Frozen { resume_to: }`. pub fn transition_to_frozen(story_id: &str) -> Result<(), ApplyError> { apply_transition(story_id, PipelineEvent::Freeze, None).map(|_| ()) } /// Unfreeze a story. /// /// Story 945: returns the story to the `resume_to` stage stored on /// `Stage::Frozen`. pub fn transition_to_unfrozen(story_id: &str) -> Result<(), ApplyError> { apply_transition(story_id, PipelineEvent::Unfreeze, None).map(|_| ()) }