Skip to main content

postproject_core/
revision.rs

1//! Durable semantic revision values for production-local change feeds.
2
3use crate::{
4    ActivityId, ActivityKind, ActivityRole, AssetId, Error, ErrorKind, ExternalIdentifier,
5    LocatorId, MediaRootId, MetadataProperty, ObjectRef, RepresentationId, ResourceId, Result,
6    RevisionId, Timestamp, ToolIdentity, TransactionId,
7};
8
9/// Maximum UTF-8 byte length of a revision message.
10pub const MAX_REVISION_MESSAGE_BYTES: usize = 4_096;
11/// Maximum revisions returned by one change-feed page.
12pub const MAX_REVISION_PAGE_SIZE: u32 = 1_000;
13
14/// Identity of the integrating application or process that committed a revision.
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct OriginIdentity(ToolIdentity);
17
18impl OriginIdentity {
19    /// Creates a bounded origin identity with an optional version and URI.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`ErrorKind::InvalidArgument`] under the same conditions as
24    /// [`ToolIdentity::new`].
25    pub fn new(
26        name: impl Into<String>,
27        version: Option<String>,
28        uri: Option<String>,
29    ) -> Result<Self> {
30        ToolIdentity::new(name, version, uri).map(Self)
31    }
32
33    /// Returns the integrating application or process name.
34    #[must_use]
35    pub fn name(&self) -> &str {
36        self.0.name()
37    }
38
39    /// Returns the optional exact application version or build.
40    #[must_use]
41    pub fn version(&self) -> Option<&str> {
42        self.0.version()
43    }
44
45    /// Returns the optional canonical application or vendor URI.
46    #[must_use]
47    pub fn uri(&self) -> Option<&str> {
48        self.0.uri()
49    }
50}
51
52/// One committed production mutation transaction in local sequence order.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct Revision {
55    id: RevisionId,
56    sequence: u64,
57    transaction_id: TransactionId,
58    committed_at: Timestamp,
59    origin: Option<OriginIdentity>,
60    message: Option<String>,
61}
62
63/// Optional origin and message applied to one transaction's revision.
64#[derive(Clone, Debug, Default, Eq, PartialEq)]
65pub struct RevisionContext {
66    origin: Option<OriginIdentity>,
67    message: Option<String>,
68}
69
70impl RevisionContext {
71    /// Creates validated context for a future committed revision.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`ErrorKind::InvalidArgument`] for an empty, oversized, or
76    /// NUL-containing message.
77    pub fn new(origin: Option<OriginIdentity>, message: Option<String>) -> Result<Self> {
78        validate_revision_message(message.as_deref())?;
79        Ok(Self { origin, message })
80    }
81
82    /// Returns the optional integrating application/process identity.
83    #[must_use]
84    pub const fn origin(&self) -> Option<&OriginIdentity> {
85        self.origin.as_ref()
86    }
87
88    /// Returns the optional human-facing revision message.
89    #[must_use]
90    pub fn message(&self) -> Option<&str> {
91        self.message.as_deref()
92    }
93}
94
95/// One semantic mutation recorded in a durable revision.
96#[derive(Clone, Debug, Eq, PartialEq)]
97#[non_exhaustive]
98pub enum RevisionEventKind {
99    /// A logical asset and its import aggregate were created.
100    AssetImported {
101        /// Imported logical asset.
102        asset_id: AssetId,
103    },
104    /// A representation was attached to an asset.
105    RepresentationAdded {
106        /// Owning logical asset.
107        asset_id: AssetId,
108        /// Added representation.
109        representation_id: RepresentationId,
110    },
111    /// A storage resource was created.
112    ResourceAdded {
113        /// Added resource.
114        resource_id: ResourceId,
115    },
116    /// A resource was attached to a representation's content structure.
117    RepresentationResourceAdded {
118        /// Owning representation.
119        representation_id: RepresentationId,
120        /// Attached resource.
121        resource_id: ResourceId,
122        /// Stable structural position within the representation.
123        position: u32,
124    },
125    /// A resource locator was added or explicitly confirmed.
126    LocatorAdded {
127        /// Located resource.
128        resource_id: ResourceId,
129        /// Added locator.
130        locator_id: LocatorId,
131    },
132    /// A superseded resource locator was retired.
133    LocatorRetired {
134        /// Resource that owned the retired locator.
135        resource_id: ResourceId,
136        /// Retired locator.
137        locator_id: LocatorId,
138    },
139    /// A resolver media root was added.
140    MediaRootAdded {
141        /// Added media root.
142        media_root_id: MediaRootId,
143    },
144    /// A resolver media root was enabled or disabled.
145    MediaRootEnabledChanged {
146        /// Updated media root.
147        media_root_id: MediaRootId,
148        /// New resolver participation state.
149        enabled: bool,
150    },
151    /// A resolver media root was removed.
152    MediaRootRemoved {
153        /// Removed media root.
154        media_root_id: MediaRootId,
155    },
156    /// An exact external identifier attachment was added.
157    ExternalIdentifierAdded {
158        /// Object receiving the identifier.
159        target: ObjectRef,
160        /// Added external identifier.
161        identifier: ExternalIdentifier,
162    },
163    /// An exact external identifier attachment was removed.
164    ExternalIdentifierRemoved {
165        /// Object losing the identifier.
166        target: ObjectRef,
167        /// Removed external identifier.
168        identifier: ExternalIdentifier,
169    },
170    /// One metadata property's values were appended or replaced.
171    MetadataAddedOrReplaced {
172        /// Object whose metadata changed.
173        target: ObjectRef,
174        /// Property that consumers should re-query.
175        property: MetadataProperty,
176    },
177    /// One metadata property was removed.
178    MetadataRemoved {
179        /// Object whose metadata changed.
180        target: ObjectRef,
181        /// Removed property.
182        property: MetadataProperty,
183    },
184    /// A production activity was created.
185    ActivityCreated {
186        /// Added activity.
187        activity_id: ActivityId,
188        /// Extensible activity kind.
189        kind: ActivityKind,
190    },
191    /// A production activity input edge was added.
192    ActivityInputAdded {
193        /// Owning activity.
194        activity_id: ActivityId,
195        /// Consumed representation.
196        representation_id: RepresentationId,
197        /// Optional semantic edge role.
198        role: Option<ActivityRole>,
199    },
200    /// A production activity output edge was added.
201    ActivityOutputAdded {
202        /// Owning activity.
203        activity_id: ActivityId,
204        /// Produced representation.
205        representation_id: RepresentationId,
206        /// Optional semantic edge role.
207        role: Option<ActivityRole>,
208    },
209}
210
211/// One deterministically ordered semantic event within a revision.
212#[derive(Clone, Debug, Eq, PartialEq)]
213pub struct RevisionEvent {
214    revision_id: RevisionId,
215    position: u32,
216    kind: RevisionEventKind,
217}
218
219impl RevisionEvent {
220    /// Creates an event at its stable zero-based revision position.
221    #[must_use]
222    pub const fn new(revision_id: RevisionId, position: u32, kind: RevisionEventKind) -> Self {
223        Self {
224            revision_id,
225            position,
226            kind,
227        }
228    }
229
230    /// Returns the revision that owns the event.
231    #[must_use]
232    pub const fn revision_id(&self) -> RevisionId {
233        self.revision_id
234    }
235
236    /// Returns the zero-based stable position within the revision.
237    #[must_use]
238    pub const fn position(&self) -> u32 {
239        self.position
240    }
241
242    /// Returns the semantic mutation payload.
243    #[must_use]
244    pub const fn kind(&self) -> &RevisionEventKind {
245        &self.kind
246    }
247}
248
249impl Revision {
250    /// Creates a complete durable revision value.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`ErrorKind::InvalidArgument`] for sequence zero or an empty,
255    /// oversized, or NUL-containing message.
256    pub fn new(
257        id: RevisionId,
258        sequence: u64,
259        transaction_id: TransactionId,
260        committed_at: Timestamp,
261        origin: Option<OriginIdentity>,
262        message: Option<String>,
263    ) -> Result<Self> {
264        if sequence == 0 {
265            return Err(Error::new(
266                ErrorKind::InvalidArgument,
267                "revision sequence must be greater than zero",
268            ));
269        }
270        validate_revision_message(message.as_deref())?;
271        Ok(Self {
272            id,
273            sequence,
274            transaction_id,
275            committed_at,
276            origin,
277            message,
278        })
279    }
280
281    /// Returns the stable revision identity.
282    #[must_use]
283    pub const fn id(&self) -> RevisionId {
284        self.id
285    }
286
287    /// Returns the monotonically increasing production-local sequence.
288    #[must_use]
289    pub const fn sequence(&self) -> u64 {
290        self.sequence
291    }
292
293    /// Returns the transaction that produced this revision.
294    #[must_use]
295    pub const fn transaction_id(&self) -> TransactionId {
296        self.transaction_id
297    }
298
299    /// Returns the durable commit timestamp.
300    #[must_use]
301    pub const fn committed_at(&self) -> Timestamp {
302        self.committed_at
303    }
304
305    /// Returns the optional integrating application/process identity.
306    #[must_use]
307    pub const fn origin(&self) -> Option<&OriginIdentity> {
308        self.origin.as_ref()
309    }
310
311    /// Returns the optional human-facing commit message.
312    #[must_use]
313    pub fn message(&self) -> Option<&str> {
314        self.message.as_deref()
315    }
316}
317
318fn validate_revision_message(message: Option<&str>) -> Result<()> {
319    if message.is_some_and(|message| {
320        message.is_empty() || message.len() > MAX_REVISION_MESSAGE_BYTES || message.contains('\0')
321    }) {
322        return Err(Error::new(
323            ErrorKind::InvalidArgument,
324            format!(
325                "revision message must contain 1-{MAX_REVISION_MESSAGE_BYTES} UTF-8 bytes without NUL"
326            ),
327        ));
328    }
329    Ok(())
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn revision_preserves_transaction_context() {
338        let origin = OriginIdentity::new(
339            "Editorial host",
340            Some("2.4.1".to_owned()),
341            Some("https://example.com/editor".to_owned()),
342        )
343        .expect("valid origin");
344        let revision = Revision::new(
345            RevisionId::new(),
346            7,
347            TransactionId::new(),
348            Timestamp::from_unix_micros(42),
349            Some(origin),
350            Some("Import camera original".to_owned()),
351        )
352        .expect("valid revision");
353
354        assert_eq!(revision.sequence(), 7);
355        assert_eq!(revision.committed_at().as_unix_micros(), 42);
356        assert_eq!(revision.origin().unwrap().name(), "Editorial host");
357        assert_eq!(revision.message(), Some("Import camera original"));
358    }
359
360    #[test]
361    fn revision_rejects_ambiguous_context() {
362        let create = |sequence, message| {
363            Revision::new(
364                RevisionId::new(),
365                sequence,
366                TransactionId::new(),
367                Timestamp::from_unix_micros(0),
368                None,
369                message,
370            )
371        };
372
373        assert!(create(0, None).is_err());
374        assert!(create(1, Some(String::new())).is_err());
375        assert!(create(1, Some("bad\0message".to_owned())).is_err());
376        assert!(create(1, Some("x".repeat(MAX_REVISION_MESSAGE_BYTES + 1))).is_err());
377    }
378
379    #[test]
380    fn events_identify_semantic_targets_in_stable_order() {
381        let revision_id = RevisionId::new();
382        let asset_id = AssetId::new();
383        let representation_id = RepresentationId::new();
384        let events = [
385            RevisionEvent::new(
386                revision_id,
387                0,
388                RevisionEventKind::AssetImported { asset_id },
389            ),
390            RevisionEvent::new(
391                revision_id,
392                1,
393                RevisionEventKind::RepresentationAdded {
394                    asset_id,
395                    representation_id,
396                },
397            ),
398        ];
399
400        assert_eq!(events[0].revision_id(), revision_id);
401        assert_eq!(events[1].position(), 1);
402        assert!(matches!(
403            events[1].kind(),
404            RevisionEventKind::RepresentationAdded {
405                representation_id: id,
406                ..
407            } if *id == representation_id
408        ));
409    }
410}