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