Skip to main content

repo/
state_attachments.rs

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