maincopy_server/domain/publication/
visibility.rs1use std::sync::Arc;
2
3use markdown_compiler::{PostId, PostRevisionDigest};
4use thiserror::Error;
5use time::{OffsetDateTime, UtcOffset};
6
7#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct PublishedPostRevision {
10 pub(crate) post_id: PostId,
11 pub(crate) revision: PostRevisionDigest,
12 pub(crate) published_at: OffsetDateTime,
13}
14
15impl PublishedPostRevision {
16 pub fn new(
17 post_id: PostId,
18 revision: PostRevisionDigest,
19 published_at: OffsetDateTime,
20 ) -> Self {
21 Self {
22 post_id,
23 revision,
24 published_at: published_at.to_offset(UtcOffset::UTC),
25 }
26 }
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct PublicLedgerProjection {
36 entries: Arc<[PublishedPostRevision]>,
37}
38
39impl PublicLedgerProjection {
40 pub fn empty() -> Self {
41 Self {
42 entries: Arc::from([]),
43 }
44 }
45
46 pub fn len(&self) -> usize {
47 self.entries.len()
48 }
49
50 pub fn is_empty(&self) -> bool {
51 self.entries.is_empty()
52 }
53
54 pub(crate) fn published_post(&self, post_id: &PostId) -> Option<&PublishedPostRevision> {
56 self.entries
57 .binary_search_by(|entry| entry.post_id.cmp(post_id))
58 .ok()
59 .map(|index| &self.entries[index])
60 }
61
62 pub(crate) fn published_posts(&self) -> impl ExactSizeIterator<Item = &PublishedPostRevision> {
64 self.entries.iter()
65 }
66
67 pub(crate) fn revision_keys(
69 &self,
70 ) -> impl ExactSizeIterator<Item = (PostId, PostRevisionDigest)> + '_ {
71 self.entries
72 .iter()
73 .map(|entry| (entry.post_id.clone(), entry.revision.clone()))
74 }
75
76 pub(crate) fn try_from_exact_entries(
77 entries: impl IntoIterator<Item = PublishedPostRevision>,
78 ) -> Result<Self, PublicLedgerProjectionError> {
79 let mut entries: Vec<_> = entries.into_iter().collect();
80 entries.sort_by(|left, right| left.post_id.cmp(&right.post_id));
81 for pair in entries.windows(2) {
82 if pair[0].post_id == pair[1].post_id {
83 return Err(PublicLedgerProjectionError {
84 post_id: pair[0].post_id.clone(),
85 });
86 }
87 }
88 Ok(Self {
89 entries: entries.into(),
90 })
91 }
92
93 #[cfg(test)]
94 pub(crate) fn with_published(
95 &self,
96 published: PublishedPostRevision,
97 ) -> Result<Self, PublicLedgerProjectionError> {
98 let insert_at = match self
99 .entries
100 .binary_search_by(|entry| entry.post_id.cmp(&published.post_id))
101 {
102 Ok(_) => {
103 return Err(PublicLedgerProjectionError {
104 post_id: published.post_id,
105 });
106 }
107 Err(insert_at) => insert_at,
108 };
109
110 let mut entries = Vec::with_capacity(self.entries.len() + 1);
111 entries.extend_from_slice(&self.entries[..insert_at]);
112 entries.push(published);
113 entries.extend_from_slice(&self.entries[insert_at..]);
114 Ok(Self {
115 entries: entries.into(),
116 })
117 }
118
119 pub(crate) fn with_approved(&self, mut approved: PublishedPostRevision) -> Self {
121 let mut entries = self.entries.to_vec();
122 match entries.binary_search_by(|entry| entry.post_id.cmp(&approved.post_id)) {
123 Ok(index) => {
124 approved.published_at = entries[index].published_at;
125 entries[index] = approved;
126 }
127 Err(index) => entries.insert(index, approved),
128 }
129 Self {
130 entries: entries.into(),
131 }
132 }
133}
134
135#[derive(Clone, Debug, Eq, Error, PartialEq)]
136#[error("public ledger contains duplicate post {post_id}")]
137pub(crate) struct PublicLedgerProjectionError {
138 post_id: PostId,
139}
140
141impl PublicLedgerProjectionError {
142 #[cfg(test)]
143 pub(crate) fn post_id(&self) -> &PostId {
144 &self.post_id
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn post_id() -> PostId {
153 PostId::parse("11111111-1111-4111-8111-111111111111").unwrap()
154 }
155
156 fn revision(byte: u8) -> PostRevisionDigest {
157 let encoded = format!("{byte:02x}").repeat(32);
158 PostRevisionDigest::parse(&format!("post-b3-v1-{encoded}")).unwrap()
159 }
160
161 #[test]
162 fn approving_an_update_preserves_the_original_publication_time() {
163 let initial_time = OffsetDateTime::from_unix_timestamp(1_000).unwrap();
164 let update_time = OffsetDateTime::from_unix_timestamp(2_000).unwrap();
165 let initial = PublicLedgerProjection::empty().with_approved(PublishedPostRevision::new(
166 post_id(),
167 revision(0x11),
168 initial_time,
169 ));
170 let updated = initial.with_approved(PublishedPostRevision::new(
171 post_id(),
172 revision(0x22),
173 update_time,
174 ));
175 let published = updated.published_post(&post_id()).unwrap();
176
177 assert_eq!(published.revision, revision(0x22));
178 assert_eq!(published.published_at, initial_time);
179 }
180}