1pub(crate) mod activation;
4pub(crate) mod admin;
5pub(crate) mod assets;
6mod provenance;
7mod routes;
8pub(crate) mod scheduler;
9pub(crate) mod store;
10pub(crate) mod ui;
11mod visibility;
12pub(crate) mod web;
13
14pub use provenance::{SourceCommit, SourceCommitAlgorithm, SourceCommitParseError};
15pub use routes::CanonicalSiteUrl;
16pub(crate) use routes::MAX_PUBLIC_ROUTES;
17pub(crate) use routes::{PublicPagePath, ROBOTS_PATH, RSS_FEED_PATH, SITEMAP_PATH};
18pub use visibility::{PublicLedgerProjection, PublishedPostRevision};
19
20use std::marker::PhantomData;
21
22use markdown_compiler::{PostId, PostRevisionDigest};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use time::{OffsetDateTime, UtcOffset};
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum CanonicalState {
30 Scheduled,
31 Activating,
32 Blocked,
33 Published,
34 Superseded,
35 Cancelled,
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ActivationBlockReason {
41 RevisionUnavailable,
42 PreviewChanged,
43}
44
45macro_rules! state_markers {
46 ($(#[$meta:meta])* $module:ident { $($state:ident),+ $(,)? }) => {
47 $(#[$meta])*
48 pub mod $module {
49 $(
50 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
51 pub struct $state;
52 )+
53 }
54 };
55}
56
57state_markers! {
58 canonical { Scheduled, Activating, Blocked, Published, Superseded, Cancelled }
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct CanonicalPublication<S = canonical::Scheduled> {
76 entity: CanonicalPublicationView,
77 marker: PhantomData<S>,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
86pub struct CanonicalPublicationView {
87 pub state: CanonicalState,
88 pub stable_post_id: PostId,
89 pub pinned_post_digest: PostRevisionDigest,
90 pub source_commit: Option<SourceCommit>,
91 #[serde(with = "time::serde::rfc3339")]
92 pub scheduled_at: OffsetDateTime,
93 #[serde(with = "time::serde::rfc3339::option")]
94 pub activation_started_at: Option<OffsetDateTime>,
95 #[serde(with = "time::serde::rfc3339::option")]
96 pub published_at: Option<OffsetDateTime>,
97 pub current_published_digest: Option<PostRevisionDigest>,
98 pub block_reason: Option<ActivationBlockReason>,
99 pub version: u64,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
104pub enum CanonicalPublicationStatus {
105 Scheduled(CanonicalPublication<canonical::Scheduled>),
106 Activating(CanonicalPublication<canonical::Activating>),
107 Blocked(CanonicalPublication<canonical::Blocked>),
108 Published(CanonicalPublication<canonical::Published>),
109 Superseded(CanonicalPublication<canonical::Superseded>),
110 Cancelled(CanonicalPublication<canonical::Cancelled>),
111}
112
113impl TryFrom<CanonicalPublicationView> for CanonicalPublicationStatus {
114 type Error = RehydrationError;
115
116 fn try_from(mut view: CanonicalPublicationView) -> Result<Self, Self::Error> {
117 view.validate()?;
118 view.normalize_timestamps();
119 let state = view.state;
120 Ok(match state {
121 CanonicalState::Scheduled => Self::Scheduled(canonical_from_view(view)),
122 CanonicalState::Activating => Self::Activating(canonical_from_view(view)),
123 CanonicalState::Blocked => Self::Blocked(canonical_from_view(view)),
124 CanonicalState::Published => Self::Published(canonical_from_view(view)),
125 CanonicalState::Superseded => Self::Superseded(canonical_from_view(view)),
126 CanonicalState::Cancelled => Self::Cancelled(canonical_from_view(view)),
127 })
128 }
129}
130
131fn canonical_from_view<S>(entity: CanonicalPublicationView) -> CanonicalPublication<S> {
132 CanonicalPublication {
133 entity,
134 marker: PhantomData,
135 }
136}
137
138impl CanonicalPublicationView {
139 fn validate(&self) -> Result<(), RehydrationError> {
140 let minimum_version = match self.state {
141 CanonicalState::Scheduled => 1,
142 CanonicalState::Activating => 2,
143 CanonicalState::Blocked | CanonicalState::Published => 3,
144 CanonicalState::Superseded => 4,
145 CanonicalState::Cancelled => 2,
146 };
147 if self.version < minimum_version {
148 return Err(RehydrationError::CanonicalVersion {
149 state: self.state,
150 version: self.version,
151 minimum: minimum_version,
152 });
153 }
154
155 let fields_are_valid = match (
156 self.activation_started_at,
157 self.published_at,
158 self.current_published_digest.as_ref(),
159 self.block_reason,
160 ) {
161 (None, None, None, None) => {
162 matches!(
163 self.state,
164 CanonicalState::Scheduled | CanonicalState::Cancelled
165 )
166 }
167 (Some(_), None, None, None) => self.state == CanonicalState::Activating,
168 (Some(_), None, None, Some(_)) => {
169 matches!(
170 self.state,
171 CanonicalState::Blocked | CanonicalState::Cancelled
172 )
173 }
174 (Some(started), Some(published), Some(_), None) => {
175 matches!(
176 self.state,
177 CanonicalState::Published | CanonicalState::Superseded
178 ) && started == published
179 }
180 _ => false,
181 };
182 fields_are_valid
183 .then_some(())
184 .ok_or(RehydrationError::CanonicalFields { state: self.state })
185 }
186
187 fn normalize_timestamps(&mut self) {
188 self.scheduled_at = self.scheduled_at.to_offset(UtcOffset::UTC);
189 self.activation_started_at = self
190 .activation_started_at
191 .map(|timestamp| timestamp.to_offset(UtcOffset::UTC));
192 self.published_at = self
193 .published_at
194 .map(|timestamp| timestamp.to_offset(UtcOffset::UTC));
195 }
196}
197
198impl CanonicalPublication<canonical::Scheduled> {
199 pub(crate) fn reschedule(
201 self,
202 expected_version: u64,
203 scheduled_at: OffsetDateTime,
204 ) -> Result<Self, TransitionFailure<Self>> {
205 let mut publication = self.require_version(expected_version)?;
206 publication.entity.scheduled_at = scheduled_at.to_offset(UtcOffset::UTC);
207 Ok(publication.transition(CanonicalState::Scheduled))
208 }
209
210 pub fn schedule(
211 stable_post_id: PostId,
212 pinned_post_digest: PostRevisionDigest,
213 source_commit: Option<SourceCommit>,
214 scheduled_at: OffsetDateTime,
215 ) -> Self {
216 Self {
217 entity: CanonicalPublicationView {
218 state: CanonicalState::Scheduled,
219 stable_post_id,
220 pinned_post_digest,
221 source_commit,
222 scheduled_at: scheduled_at.to_offset(UtcOffset::UTC),
223 activation_started_at: None,
224 published_at: None,
225 current_published_digest: None,
226 block_reason: None,
227 version: 1,
228 },
229 marker: PhantomData,
230 }
231 }
232
233 pub fn begin_activation(
234 self,
235 expected_version: u64,
236 now: OffsetDateTime,
237 ) -> Result<CanonicalPublication<canonical::Activating>, TransitionFailure<Self>> {
238 let publication = self.require_version(expected_version)?;
239 if now < publication.entity.scheduled_at {
240 let error = TransitionError::CanonicalNotDue {
241 scheduled_at: publication.entity.scheduled_at,
242 now,
243 };
244 return Err(TransitionFailure::new(publication, error));
245 }
246 Ok(publication.activate(now))
247 }
248
249 pub fn begin_activation_now(
250 self,
251 expected_version: u64,
252 now: OffsetDateTime,
253 ) -> Result<CanonicalPublication<canonical::Activating>, TransitionFailure<Self>> {
254 let publication = self.require_version(expected_version)?;
255 Ok(publication.activate(now))
256 }
257
258 fn activate(mut self, now: OffsetDateTime) -> CanonicalPublication<canonical::Activating> {
259 self.entity.activation_started_at = Some(now.to_offset(UtcOffset::UTC));
260 self.transition(CanonicalState::Activating)
261 }
262}
263
264impl CanonicalPublication<canonical::Activating> {
265 pub fn block_activation(
266 self,
267 expected_version: u64,
268 reason: ActivationBlockReason,
269 ) -> Result<CanonicalPublication<canonical::Blocked>, TransitionFailure<Self>> {
270 let mut publication = self.require_version(expected_version)?;
271 publication.entity.block_reason = Some(reason);
272 Ok(publication.transition(CanonicalState::Blocked))
273 }
274
275 pub fn commit_published(
277 self,
278 expected_version: u64,
279 ) -> Result<CanonicalPublication<canonical::Published>, TransitionFailure<Self>> {
280 let mut publication = self.require_version(expected_version)?;
281 let activation_started_at = publication
282 .entity
283 .activation_started_at
284 .expect("activating publications always have an activation timestamp");
285 publication.entity.published_at = Some(activation_started_at);
286 publication.entity.current_published_digest =
287 Some(publication.entity.pinned_post_digest.clone());
288 publication.entity.block_reason = None;
289 Ok(publication.transition(CanonicalState::Published))
290 }
291}
292
293impl CanonicalPublication<canonical::Published> {
294 pub fn supersede(
296 self,
297 expected_version: u64,
298 ) -> Result<CanonicalPublication<canonical::Superseded>, TransitionFailure<Self>> {
299 let publication = self.require_version(expected_version)?;
300 Ok(publication.transition(CanonicalState::Superseded))
301 }
302}
303
304impl CanonicalPublication<canonical::Blocked> {
305 pub fn retry_blocked(
306 self,
307 expected_version: u64,
308 now: OffsetDateTime,
309 ) -> Result<CanonicalPublication<canonical::Activating>, TransitionFailure<Self>> {
310 let mut publication = self.require_version(expected_version)?;
311 publication.entity.activation_started_at = Some(now.to_offset(UtcOffset::UTC));
312 publication.entity.block_reason = None;
313 Ok(publication.transition(CanonicalState::Activating))
314 }
315}
316
317impl<S> CanonicalPublication<S> {
318 pub const fn view(&self) -> &CanonicalPublicationView {
319 &self.entity
320 }
321
322 pub fn into_view(self) -> CanonicalPublicationView {
323 self.entity
324 }
325
326 fn require_version(self, expected: u64) -> Result<Self, TransitionFailure<Self>> {
327 if self.entity.version == expected {
328 Ok(self)
329 } else {
330 let actual = self.entity.version;
331 Err(TransitionFailure::new(
332 self,
333 TransitionError::VersionConflict { expected, actual },
334 ))
335 }
336 }
337
338 fn transition<T>(mut self, state: CanonicalState) -> CanonicalPublication<T> {
339 self.entity.state = state;
340 self.entity.version += 1;
341 CanonicalPublication {
342 entity: self.entity,
343 marker: PhantomData,
344 }
345 }
346}
347
348macro_rules! cancellable_publications {
349 ($($state:ty),+ $(,)?) => {
350 $(
351 impl CanonicalPublication<$state> {
352 pub fn cancel(
353 self,
354 expected_version: u64,
355 ) -> Result<CanonicalPublication<canonical::Cancelled>, TransitionFailure<Self>> {
356 let publication = self.require_version(expected_version)?;
357 Ok(publication.transition(CanonicalState::Cancelled))
358 }
359 }
360 )+
361 };
362}
363
364cancellable_publications!(canonical::Scheduled, canonical::Blocked);
365
366#[derive(Clone, Debug, Eq, PartialEq, Error)]
368#[error("{error}")]
369pub struct TransitionFailure<M> {
370 pub machine: Box<M>,
371 #[source]
372 pub error: TransitionError,
373}
374
375impl<M> TransitionFailure<M> {
376 fn new(machine: M, error: TransitionError) -> Self {
377 Self {
378 machine: Box::new(machine),
379 error,
380 }
381 }
382}
383
384#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)]
385pub enum RehydrationError {
386 #[error(
387 "canonical publication {state:?} requires version {minimum} or later, received {version}"
388 )]
389 CanonicalVersion {
390 state: CanonicalState,
391 version: u64,
392 minimum: u64,
393 },
394 #[error("canonical publication fields are inconsistent with {state:?} state")]
395 CanonicalFields { state: CanonicalState },
396}
397
398#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)]
399pub enum TransitionError {
400 #[error("resource version conflict: expected {expected}, actual {actual}")]
401 VersionConflict { expected: u64, actual: u64 },
402 #[error("canonical publication is not due until {scheduled_at:?}; current time is {now:?}")]
403 CanonicalNotDue {
404 scheduled_at: OffsetDateTime,
405 now: OffsetDateTime,
406 },
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 const POST_A: &str = "11111111-1111-4111-8111-111111111111";
414 const DIGEST_A: &str =
415 "post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111";
416 const DIGEST_B: &str =
417 "post-b3-v1-2222222222222222222222222222222222222222222222222222222222222222";
418 const SOURCE_COMMIT_A: &str = "git-sha1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
419
420 fn at(seconds: i64) -> OffsetDateTime {
421 OffsetDateTime::from_unix_timestamp(seconds).unwrap()
422 }
423 fn post_id(value: &str) -> PostId {
424 PostId::parse(value).unwrap()
425 }
426 fn digest(value: &str) -> PostRevisionDigest {
427 PostRevisionDigest::parse(value).unwrap()
428 }
429 fn scheduled_publication(
430 scheduled_at: OffsetDateTime,
431 ) -> CanonicalPublication<canonical::Scheduled> {
432 CanonicalPublication::schedule(post_id(POST_A), digest(DIGEST_A), None, scheduled_at)
433 }
434 fn published_publication(
435 scheduled_at: OffsetDateTime,
436 ) -> CanonicalPublication<canonical::Published> {
437 scheduled_publication(scheduled_at)
438 .begin_activation(1, scheduled_at)
439 .unwrap()
440 .commit_published(2)
441 .unwrap()
442 }
443 fn restore_canonical(view: CanonicalPublicationView) -> CanonicalPublicationStatus {
444 let encoded = serde_json::to_string(&view).unwrap();
445 let decoded: CanonicalPublicationView = serde_json::from_str(&encoded).unwrap();
446 CanonicalPublicationStatus::try_from(decoded).unwrap()
447 }
448
449 #[test]
450 fn canonical_success_path_assigns_publication_time() {
451 let publication = CanonicalPublication::schedule(
452 post_id(POST_A),
453 digest(DIGEST_A),
454 Some(SourceCommit::parse(SOURCE_COMMIT_A).unwrap()),
455 at(10),
456 )
457 .begin_activation(1, at(11))
458 .unwrap()
459 .commit_published(2)
460 .unwrap();
461 let view = publication.view();
462
463 assert_eq!(view.state, CanonicalState::Published);
464 assert_eq!(view.published_at, Some(at(11)));
465 assert_eq!(
466 view.current_published_digest
467 .as_ref()
468 .map(PostRevisionDigest::as_str),
469 Some(DIGEST_A)
470 );
471 assert_eq!(view.version, 3);
472
473 fn requires_published(_: &CanonicalPublication<canonical::Published>) {}
474 requires_published(&publication);
475 }
476
477 #[test]
478 fn scheduled_activation_waits_until_due_but_publish_now_does_not() {
479 let scheduled = scheduled_publication(at(20));
480 let failure = scheduled.begin_activation(1, at(19)).unwrap_err();
481 assert_eq!(
482 failure.error,
483 TransitionError::CanonicalNotDue {
484 scheduled_at: at(20),
485 now: at(19),
486 }
487 );
488
489 let activating = (*failure.machine).begin_activation_now(1, at(19)).unwrap();
490 assert_eq!(activating.view().state, CanonicalState::Activating);
491 assert_eq!(activating.view().activation_started_at, Some(at(19)));
492 }
493
494 #[test]
495 fn blocked_retry_preserves_pinned_revision() {
496 let publication = scheduled_publication(at(10))
497 .begin_activation(1, at(10))
498 .unwrap()
499 .block_activation(2, ActivationBlockReason::RevisionUnavailable)
500 .unwrap();
501 assert_eq!(
502 publication.view().block_reason,
503 Some(ActivationBlockReason::RevisionUnavailable)
504 );
505 let publication = publication.retry_blocked(3, at(12)).unwrap();
506 let view = publication.view();
507 assert_eq!(view.state, CanonicalState::Activating);
508 assert_eq!(view.pinned_post_digest.as_str(), DIGEST_A);
509 assert_eq!(view.published_at, None);
510 assert_eq!(view.block_reason, None);
511 assert_eq!(view.activation_started_at, Some(at(12)));
512 }
513
514 #[test]
515 fn stale_version_is_rejected() {
516 let failure = scheduled_publication(at(10))
517 .begin_activation(2, at(10))
518 .unwrap_err();
519 assert_eq!(
520 failure.error,
521 TransitionError::VersionConflict {
522 expected: 2,
523 actual: 1
524 }
525 );
526 assert_eq!(failure.machine.view().version, 1);
527 assert_eq!(failure.machine.view().state, CanonicalState::Scheduled);
528 }
529
530 #[test]
531 fn state_names_are_stable() {
532 for (state, name) in [
533 (CanonicalState::Scheduled, "scheduled"),
534 (CanonicalState::Activating, "activating"),
535 (CanonicalState::Blocked, "blocked"),
536 (CanonicalState::Published, "published"),
537 (CanonicalState::Superseded, "superseded"),
538 (CanonicalState::Cancelled, "cancelled"),
539 ] {
540 assert_eq!(
541 serde_json::to_value(state).unwrap(),
542 serde_json::json!(name)
543 );
544 }
545 assert_eq!(
546 serde_json::to_value(ActivationBlockReason::RevisionUnavailable).unwrap(),
547 serde_json::json!("revision_unavailable")
548 );
549 assert_eq!(
550 serde_json::to_value(ActivationBlockReason::PreviewChanged).unwrap(),
551 serde_json::json!("preview_changed")
552 );
553 }
554
555 #[test]
556 fn canonical_view_preserves_the_exact_flat_json_contract() {
557 let publication = CanonicalPublication::schedule(
558 post_id(POST_A),
559 digest(DIGEST_A),
560 Some(SourceCommit::parse(SOURCE_COMMIT_A).unwrap()),
561 at(10),
562 );
563 assert_eq!(
564 serde_json::to_value(publication.view()).unwrap(),
565 serde_json::json!({
566 "state": "scheduled",
567 "stable_post_id": POST_A,
568 "pinned_post_digest": DIGEST_A,
569 "source_commit": SOURCE_COMMIT_A,
570 "scheduled_at": "1970-01-01T00:00:10Z",
571 "activation_started_at": null,
572 "published_at": null,
573 "current_published_digest": null,
574 "block_reason": null,
575 "version": 1,
576 })
577 );
578 }
579
580 #[test]
581 fn canonical_views_rehydrate_every_typed_state() {
582 let views = [
583 scheduled_publication(at(10)).into_view(),
584 scheduled_publication(at(10))
585 .begin_activation(1, at(10))
586 .unwrap()
587 .into_view(),
588 scheduled_publication(at(10))
589 .begin_activation(1, at(10))
590 .unwrap()
591 .block_activation(2, ActivationBlockReason::RevisionUnavailable)
592 .unwrap()
593 .into_view(),
594 published_publication(at(10)).into_view(),
595 published_publication(at(10))
596 .supersede(3)
597 .unwrap()
598 .into_view(),
599 scheduled_publication(at(10)).cancel(1).unwrap().into_view(),
600 scheduled_publication(at(10))
601 .begin_activation(1, at(10))
602 .unwrap()
603 .block_activation(2, ActivationBlockReason::RevisionUnavailable)
604 .unwrap()
605 .cancel(3)
606 .unwrap()
607 .into_view(),
608 ];
609
610 for expected in views {
611 let state = expected.state;
612 let actual = match restore_canonical(expected.clone()) {
613 CanonicalPublicationStatus::Scheduled(publication) => publication.into_view(),
614 CanonicalPublicationStatus::Activating(publication) => publication.into_view(),
615 CanonicalPublicationStatus::Blocked(publication) => publication.into_view(),
616 CanonicalPublicationStatus::Published(publication) => publication.into_view(),
617 CanonicalPublicationStatus::Superseded(publication) => publication.into_view(),
618 CanonicalPublicationStatus::Cancelled(publication) => publication.into_view(),
619 };
620 assert_eq!(actual.state, state);
621 assert_eq!(actual, expected);
622 }
623 }
624
625 #[test]
626 fn rehydration_rejects_unreachable_state_data() {
627 let mut canonical = scheduled_publication(at(10)).into_view();
628 canonical.state = CanonicalState::Published;
629 canonical.version = 3;
630 assert_eq!(
631 CanonicalPublicationStatus::try_from(canonical).unwrap_err(),
632 RehydrationError::CanonicalFields {
633 state: CanonicalState::Published
634 }
635 );
636
637 let mut canonical = scheduled_publication(at(10)).into_view();
638 canonical.published_at = Some(at(10));
639 assert_eq!(
640 CanonicalPublicationStatus::try_from(canonical).unwrap_err(),
641 RehydrationError::CanonicalFields {
642 state: CanonicalState::Scheduled
643 }
644 );
645
646 let mut reloaded = published_publication(at(10)).into_view();
647 reloaded.current_published_digest = Some(digest(DIGEST_B));
648 assert!(matches!(
649 CanonicalPublicationStatus::try_from(reloaded),
650 Ok(CanonicalPublicationStatus::Published(_))
651 ));
652
653 let mut canonical = scheduled_publication(at(10)).into_view();
654 canonical.version = 0;
655 assert_eq!(
656 CanonicalPublicationStatus::try_from(canonical).unwrap_err(),
657 RehydrationError::CanonicalVersion {
658 state: CanonicalState::Scheduled,
659 version: 0,
660 minimum: 1,
661 }
662 );
663 }
664
665 #[test]
666 fn stored_timestamps_are_normalized_to_utc() {
667 let east = UtcOffset::from_hms(2, 0, 0).unwrap();
668 let represented_with_offset = at(10).to_offset(east);
669 let publication = scheduled_publication(represented_with_offset);
670 assert_eq!(publication.view().scheduled_at.offset(), UtcOffset::UTC);
671
672 let publication = publication
673 .begin_activation(1, represented_with_offset)
674 .unwrap();
675 assert_eq!(
676 publication.view().activation_started_at.unwrap().offset(),
677 UtcOffset::UTC
678 );
679 let publication = publication.commit_published(2).unwrap();
680 assert_eq!(publication.view().published_at, Some(at(10)));
681
682 let mut persisted = scheduled_publication(at(10)).into_view();
683 persisted.scheduled_at = represented_with_offset;
684 let CanonicalPublicationStatus::Scheduled(restored) =
685 CanonicalPublicationStatus::try_from(persisted).unwrap()
686 else {
687 panic!("expected a scheduled publication");
688 };
689 assert_eq!(restored.view().scheduled_at.offset(), UtcOffset::UTC);
690 }
691
692 #[test]
693 fn all_cancellable_states_reach_typed_cancelled_states() {
694 let publication = scheduled_publication(at(10)).cancel(1).unwrap();
695 assert_eq!(publication.view().state, CanonicalState::Cancelled);
696 assert_eq!(publication.view().published_at, None);
697
698 let publication = scheduled_publication(at(10))
699 .begin_activation(1, at(10))
700 .unwrap()
701 .block_activation(2, ActivationBlockReason::RevisionUnavailable)
702 .unwrap()
703 .cancel(3)
704 .unwrap();
705 assert_eq!(publication.view().state, CanonicalState::Cancelled);
706 }
707}