Skip to main content

repo/
state_attachments.rs

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