Skip to main content

repo/
state_attachments.rs

1// SPDX-License-Identifier: Apache-2.0
2
3/// The kind of a state attachment, re-exported from the `objects` crate where
4/// it lives as a first-class projection of `StateAttachmentBody` (heddle#1080).
5/// Derive kind from a body with `body.kind()`.
6pub use objects::object::StateAttachmentKind;
7use objects::{
8    object::{StateAttachment, StateAttachmentId, StateId},
9    store::ObjectStore,
10};
11use oplog::OpLogBackend;
12use refs::RefBackend;
13
14use crate::{HeddleError, Repository, Result};
15
16impl<R, O, S> Repository<R, O, S>
17where
18    R: RefBackend,
19    O: OpLogBackend,
20    S: ObjectStore,
21{
22    pub fn put_state_attachment(&self, attachment: &StateAttachment) -> Result<StateAttachmentId> {
23        if !self.store().has_state(&attachment.state_id)? {
24            return Err(HeddleError::StateNotFound(attachment.state_id));
25        }
26
27        if let Some(prior_id) = attachment.supersedes {
28            if attachment.body.kind() == StateAttachmentKind::Signature {
29                return Err(HeddleError::InvalidObject(
30                    "state-signature evidence is append-only and cannot supersede another signature"
31                        .to_string(),
32                ));
33            }
34            let prior = self
35                .get_state_attachment(&attachment.state_id, &prior_id)?
36                .ok_or_else(|| HeddleError::NotFound(format!("state attachment {prior_id}")))?;
37            if prior.body.kind() != attachment.body.kind() {
38                return Err(HeddleError::InvalidObject(
39                    "state attachment can only supersede the same attachment kind".to_string(),
40                ));
41            }
42        }
43
44        let id = attachment.id();
45        if let Some(existing) = self
46            .store()
47            .get_state_attachment(&attachment.state_id, &id)?
48        {
49            return match self.get_state_attachment(&attachment.state_id, &id)? {
50                Some(_) if existing == *attachment => Ok(id),
51                _ => Err(HeddleError::InvalidObject(
52                    "state attachment address collision".to_string(),
53                )),
54            };
55        }
56        self.store().put_state_attachment(attachment)
57    }
58
59    pub fn get_state_attachment(
60        &self,
61        state_id: &StateId,
62        attachment_id: &StateAttachmentId,
63    ) -> Result<Option<StateAttachment>> {
64        self.store().get_state_attachment(state_id, attachment_id)
65    }
66
67    pub fn list_state_attachments(&self, state_id: &StateId) -> Result<Vec<StateAttachment>> {
68        let mut attachments = self.store().list_state_attachments(state_id)?;
69        attachments.sort_by_key(|attachment| (attachment.created_at, attachment.id()));
70        Ok(attachments)
71    }
72
73    pub fn latest_state_attachment(
74        &self,
75        state_id: &StateId,
76        kind: StateAttachmentKind,
77    ) -> Result<Option<StateAttachment>> {
78        let attachments: Vec<_> = self
79            .list_state_attachments(state_id)?
80            .into_iter()
81            .filter(|attachment| attachment.body.kind() == kind)
82            .collect();
83        let superseded: std::collections::HashSet<_> = attachments
84            .iter()
85            .filter_map(|attachment| attachment.supersedes)
86            .collect();
87        Ok(attachments
88            .into_iter()
89            .filter(|attachment| !superseded.contains(&attachment.id()))
90            .max_by_key(StateAttachment::id))
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use chrono::{Duration, Utc};
97    use objects::object::{
98        Attribution, ContentHash, Principal, StateAttachment, StateAttachmentBody,
99    };
100    use tempfile::TempDir;
101
102    use super::*;
103
104    fn attachment(state_id: StateId, body: StateAttachmentBody) -> StateAttachment {
105        StateAttachment {
106            state_id,
107            body,
108            attribution: Attribution::human(Principal::new("Test", "test@example.com")),
109            created_at: Utc::now(),
110            supersedes: None,
111        }
112    }
113
114    #[test]
115    fn attachment_history_preserves_state_bytes() {
116        let temp = TempDir::new().unwrap();
117        let repo = Repository::init_default(temp.path()).unwrap();
118        let state_id = repo.head().unwrap().unwrap();
119        let before = repo.store().get_state(&state_id).unwrap().unwrap();
120
121        let first = attachment(
122            state_id,
123            StateAttachmentBody::Context(ContentHash::compute(b"first")),
124        );
125        let first_id = repo.put_state_attachment(&first).unwrap();
126        let mut second = attachment(
127            state_id,
128            StateAttachmentBody::Context(ContentHash::compute(b"second")),
129        );
130        second.created_at = first.created_at + Duration::seconds(1);
131        second.supersedes = Some(first_id);
132        repo.put_state_attachment(&second).unwrap();
133
134        let latest = repo
135            .latest_state_attachment(&state_id, StateAttachmentKind::Context)
136            .unwrap()
137            .unwrap();
138        assert_eq!(latest, second);
139        assert_eq!(repo.list_state_attachments(&state_id).unwrap().len(), 2);
140        assert_eq!(repo.store().get_state(&state_id).unwrap().unwrap(), before);
141    }
142
143    #[test]
144    fn latest_follows_supersession_heads_not_wall_clock() {
145        let temp = TempDir::new().unwrap();
146        let repo = Repository::init_default(temp.path()).unwrap();
147        let state_id = repo.head().unwrap().unwrap();
148        let root = attachment(
149            state_id,
150            StateAttachmentBody::Context(ContentHash::compute(b"root")),
151        );
152        let root_id = repo.put_state_attachment(&root).unwrap();
153
154        let mut older_clock = attachment(
155            state_id,
156            StateAttachmentBody::Context(ContentHash::compute(b"older-clock")),
157        );
158        older_clock.created_at = root.created_at - Duration::seconds(10);
159        older_clock.supersedes = Some(root_id);
160        repo.put_state_attachment(&older_clock).unwrap();
161
162        let mut fork = attachment(
163            state_id,
164            StateAttachmentBody::Context(ContentHash::compute(b"fork")),
165        );
166        fork.created_at = root.created_at + Duration::seconds(10);
167        fork.supersedes = Some(root_id);
168        repo.put_state_attachment(&fork).unwrap();
169
170        let expected = [older_clock.clone(), fork.clone()]
171            .into_iter()
172            .max_by_key(StateAttachment::id)
173            .unwrap();
174        assert_eq!(
175            repo.latest_state_attachment(&state_id, StateAttachmentKind::Context)
176                .unwrap()
177                .unwrap(),
178            expected
179        );
180    }
181}