Skip to main content

aion_core/
ids.rs

1//! Strongly typed identifiers for workflows, activities, timers, and runs.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Identifier for a logical workflow.
9#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Hash)]
10pub struct WorkflowId(Uuid);
11
12impl WorkflowId {
13    /// Creates a workflow identifier from an existing UUID.
14    #[must_use]
15    pub const fn new(id: Uuid) -> Self {
16        Self(id)
17    }
18
19    /// Creates a workflow identifier with a random version 4 UUID.
20    #[must_use]
21    pub fn new_v4() -> Self {
22        Self(Uuid::new_v4())
23    }
24
25    /// Returns the UUID backing this identifier.
26    #[must_use]
27    pub const fn as_uuid(&self) -> Uuid {
28        self.0
29    }
30}
31
32impl fmt::Display for WorkflowId {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        self.0.fmt(formatter)
35    }
36}
37
38/// Canonical textual content hash identifying one loaded workflow package version.
39///
40/// History events pin every workflow run to the package version it started on.
41/// `aion-core` is a leaf crate that cannot depend on `aion-package`, so the
42/// durable form is the stable 64-character lowercase hexadecimal content-hash
43/// text; the engine parses it back to a typed content hash at its boundary.
44#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Hash)]
45pub struct PackageVersion(String);
46
47impl PackageVersion {
48    /// Creates a package version from its canonical textual content-hash form.
49    #[must_use]
50    pub fn new(version: impl Into<String>) -> Self {
51        Self(version.into())
52    }
53
54    /// Returns the canonical textual content-hash form.
55    #[must_use]
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl fmt::Display for PackageVersion {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        self.0.fmt(formatter)
64    }
65}
66
67/// Identifier for an activity scheduled within a workflow history.
68#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Hash)]
69pub struct ActivityId(u64);
70
71impl ActivityId {
72    /// Derives an activity identifier from its scheduling sequence position.
73    #[must_use]
74    pub const fn from_sequence_position(sequence_position: u64) -> Self {
75        Self(sequence_position)
76    }
77
78    /// Returns the scheduling sequence position used to derive this identifier.
79    #[must_use]
80    pub const fn sequence_position(&self) -> u64 {
81        self.0
82    }
83}
84
85impl fmt::Display for ActivityId {
86    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(formatter, "activity:{}", self.0)
88    }
89}
90
91/// Errors from identifier construction.
92#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
93pub enum IdError {
94    /// A timer name must be non-empty.
95    #[error("timer name must not be empty")]
96    EmptyTimerName,
97}
98
99/// Identifier for a timer scheduled by workflow code or by the engine.
100#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Hash)]
101pub struct TimerId(TimerIdKind);
102
103impl TimerId {
104    /// Creates an author-assigned timer identifier.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`IdError::EmptyTimerName`] if the name is empty.
109    pub fn named(name: impl Into<String>) -> Result<Self, IdError> {
110        let name = name.into();
111        if name.is_empty() {
112            return Err(IdError::EmptyTimerName);
113        }
114        Ok(Self(TimerIdKind::Named(name)))
115    }
116
117    /// Creates an engine-assigned timer identifier derived from sequence position.
118    #[must_use]
119    pub const fn anonymous(sequence_position: u64) -> Self {
120        Self(TimerIdKind::Anonymous(sequence_position))
121    }
122
123    /// Returns the author-assigned timer name, if this is a named timer.
124    #[must_use]
125    pub fn name(&self) -> Option<&str> {
126        match &self.0 {
127            TimerIdKind::Named(name) => Some(name.as_str()),
128            TimerIdKind::Anonymous(_) => None,
129        }
130    }
131
132    /// Returns the scheduling sequence position, if this is an anonymous timer.
133    #[must_use]
134    pub fn sequence_position(&self) -> Option<u64> {
135        match &self.0 {
136            TimerIdKind::Named(_) => None,
137            TimerIdKind::Anonymous(sequence_position) => Some(*sequence_position),
138        }
139    }
140}
141
142impl fmt::Display for TimerId {
143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match &self.0 {
145            TimerIdKind::Named(name) => write!(formatter, "timer:named:{name}"),
146            TimerIdKind::Anonymous(sequence_position) => {
147                write!(formatter, "timer:anonymous:{sequence_position}")
148            }
149        }
150    }
151}
152
153/// Backing representation for timer identifiers.
154#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Hash)]
155enum TimerIdKind {
156    /// Author-assigned timer name.
157    Named(String),
158    /// Engine-assigned timer sequence position.
159    Anonymous(u64),
160}
161
162/// Identifier for a concrete run of a logical workflow.
163#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Hash)]
164pub struct RunId(Uuid);
165
166impl RunId {
167    /// Creates a run identifier from an existing UUID.
168    #[must_use]
169    pub const fn new(id: Uuid) -> Self {
170        Self(id)
171    }
172
173    /// Creates a run identifier with a random version 4 UUID.
174    #[must_use]
175    pub fn new_v4() -> Self {
176        Self(Uuid::new_v4())
177    }
178
179    /// Returns the UUID backing this identifier.
180    #[must_use]
181    pub const fn as_uuid(&self) -> Uuid {
182        self.0
183    }
184}
185
186impl fmt::Display for RunId {
187    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
188        self.0.fmt(formatter)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use std::collections::HashMap;
195
196    use serde::de::DeserializeOwned;
197
198    use super::{ActivityId, IdError, RunId, TimerId, WorkflowId};
199
200    fn round_trip<T>(identifier: &T) -> Result<(), serde_json::Error>
201    where
202        T: DeserializeOwned + PartialEq + serde::Serialize + std::fmt::Debug,
203    {
204        let json = serde_json::to_string(identifier)?;
205        let decoded = serde_json::from_str::<T>(&json)?;
206        assert_eq!(*identifier, decoded);
207        Ok(())
208    }
209
210    #[test]
211    fn identifiers_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
212        round_trip(&WorkflowId::new_v4())?;
213        round_trip(&ActivityId::from_sequence_position(17))?;
214        round_trip(&TimerId::named("reminder")?)?;
215        round_trip(&TimerId::anonymous(29))?;
216        round_trip(&RunId::new_v4())?;
217        Ok(())
218    }
219
220    #[test]
221    fn uuid_identifiers_are_hash_map_keys() {
222        let workflow_id = WorkflowId::new_v4();
223        let run_id = RunId::new_v4();
224
225        let mut workflows = HashMap::new();
226        workflows.insert(workflow_id.clone(), "workflow");
227        assert_eq!(workflows.get(&workflow_id), Some(&"workflow"));
228
229        let mut runs = HashMap::new();
230        runs.insert(run_id.clone(), "run");
231        assert_eq!(runs.get(&run_id), Some(&"run"));
232    }
233
234    #[test]
235    fn sequence_identifiers_expose_positions() -> Result<(), IdError> {
236        let activity_id = ActivityId::from_sequence_position(42);
237        let timer_id = TimerId::anonymous(43);
238
239        assert_eq!(activity_id.sequence_position(), 42);
240        assert_eq!(timer_id.sequence_position(), Some(43));
241        assert_eq!(TimerId::named("deadline")?.name(), Some("deadline"));
242        Ok(())
243    }
244
245    #[test]
246    fn display_formats_are_stable() -> Result<(), IdError> {
247        let workflow_id = WorkflowId::new(uuid::Uuid::nil());
248        let run_id = RunId::new(uuid::Uuid::nil());
249
250        assert_eq!(
251            workflow_id.to_string(),
252            "00000000-0000-0000-0000-000000000000"
253        );
254        assert_eq!(run_id.to_string(), "00000000-0000-0000-0000-000000000000");
255        assert_eq!(
256            ActivityId::from_sequence_position(7).to_string(),
257            "activity:7"
258        );
259        assert_eq!(
260            TimerId::named("reminder")?.to_string(),
261            "timer:named:reminder"
262        );
263        assert_eq!(TimerId::anonymous(3).to_string(), "timer:anonymous:3");
264        Ok(())
265    }
266
267    #[test]
268    fn named_timer_rejects_empty_name() {
269        assert_eq!(TimerId::named(""), Err(IdError::EmptyTimerName));
270    }
271}