Skip to main content

maincopy_server/domain/publication/
visibility.rs

1use std::sync::Arc;
2
3use markdown_compiler::{PostId, PostRevisionDigest};
4use thiserror::Error;
5use time::{OffsetDateTime, UtcOffset};
6
7/// The exact post revision and committed publication time authorized for public use.
8#[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/// An exact, storage-neutral view of revisions authorized for one public snapshot.
30///
31/// Persistence adapters and the snapshot-transition coordinator construct the
32/// entries inside the crate. Public callers can explicitly request an empty
33/// projection, but cannot infer publication from the content catalog.
34#[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    /// Finds the exact public-ledger entry for one stable post ID.
55    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    /// Iterates the exact durable posts represented by this public projection.
63    pub(crate) fn published_posts(&self) -> impl ExactSizeIterator<Item = &PublishedPostRevision> {
64        self.entries.iter()
65    }
66
67    /// Returns the exact catalog keys that durable public visibility retains.
68    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    /// Inserts a first publication or replaces its revision at the original publication time.
120    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}