//! Kinds of remake: what makes one remake differ from another. //! //! Everything else about a remake (proposal, settle period, decision, switch, rejoin, //! replay window, quarantine, archive) is the same for every kind. A kind decides two //! things only: //! //! * the user content of the next epoch's initial document ([`RemakeKind::build`]), //! which must be deterministic: every node builds it on its own and must produce the //! same bytes, since its hash names the epoch; //! * where a write made in the old epoch after the base goes in the new one //! ([`RemakeKind::translate`]). //! //! Protocol data under `_continuum` is not a kind's business: the generic part carries //! it forward and replays it unchanged. use automerge::AutoCommit; use serde_json::Value as JsonValue; use super::snapshot::META_ROOT; use crate::document::store::{self, StoreError}; /// Name of the kind used when a request doesn't name one. pub(crate) const DEFAULT_KIND: &str = "compact"; /// What the coordinator stores in the proposal for every node. #[derive(Debug, Default)] pub(crate) struct Prepared { /// Input to [`RemakeKind::build`]; replicated with the proposal (old epoch only). pub(crate) content: JsonValue, /// Input to [`RemakeKind::translate`]; also kept in the new epoch's metadata, so /// nodes that rejoin later can translate their writes. pub(crate) translation: JsonValue, } /// Where a path written in the old epoch after the base goes. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Translation { /// To the same path (compaction). Same, /// To another path. To(Vec), /// Nowhere automatically: quarantined for an operator, with the reason. Quarantine(String), /// Nowhere: the remake declared the path obsolete. Drop, } pub(crate) trait RemakeKind: Send + Sync + 'static { /// Stable name, stored in proposals and epoch metadata. fn name(&self) -> &'static str; /// Coordinator only: validates the kind-specific part of the request. fn propose(&self, spec: &JsonValue) -> Result; /// Every node: writes the user content of the next epoch into `target`, a fresh /// document. `old_at_base` is the old epoch at the base. Must be deterministic and /// must not write under `_continuum` (anything there is replaced). fn build( &self, old_at_base: &AutoCommit, content: &JsonValue, target: &mut AutoCommit, ) -> Result<(), StoreError>; /// Every node: where a user path written after the base goes. fn translate(&self, path: &[String], translation: &JsonValue) -> Translation; } /// The kind with this name. pub(crate) fn by_name(name: &str) -> Option<&'static dyn RemakeKind> { match name { "compact" => Some(&Compact), _ => None, } } /// Paths owned by the protocol rather than by any kind. pub(crate) fn is_reserved(path: &[String]) -> bool { path.first().is_some_and(|root| root == META_ROOT) } /// Compaction: the new document has the old one's content at the base, without its /// history. Writes keep their paths. pub(crate) struct Compact; impl RemakeKind for Compact { fn name(&self) -> &'static str { "compact" } fn propose(&self, spec: &JsonValue) -> Result { match spec { JsonValue::Null => Ok(Prepared::default()), JsonValue::Object(fields) if fields.is_empty() => Ok(Prepared::default()), _ => Err("compaction takes no spec".to_owned()), } } fn build( &self, old_at_base: &AutoCommit, _content: &JsonValue, target: &mut AutoCommit, ) -> Result<(), StoreError> { store::materialize_into(old_at_base, target, &is_reserved) } fn translate(&self, _path: &[String], _translation: &JsonValue) -> Translation { Translation::Same } } /// The translations of several consecutive remakes, applied in order: a node that /// rejoins several epochs later carries its writes through every remake in between. #[derive(Default, Clone)] pub(crate) struct Translator { steps: Vec<(&'static dyn RemakeKind, JsonValue)>, } impl Translator { /// One remake. pub(crate) fn single(kind: &'static dyn RemakeKind, translation: JsonValue) -> Self { Self { steps: vec![(kind, translation)], } } pub(crate) fn push(&mut self, kind: &'static dyn RemakeKind, translation: JsonValue) { self.steps.push((kind, translation)); } /// Where `path` ends up after all steps. Reserved paths always stay put. pub(crate) fn translate(&self, path: &[String]) -> Translation { if is_reserved(path) { return Translation::Same; } let mut current = path.to_vec(); let mut moved = false; for (kind, translation) in &self.steps { match kind.translate(¤t, translation) { Translation::Same => {} Translation::To(next) => { current = next; moved = true; } stop @ (Translation::Quarantine(_) | Translation::Drop) => return stop, } } if moved { Translation::To(current) } else { Translation::Same } } } #[cfg(test)] mod tests { use super::*; /// A test kind that moves `a/...` to `b/...`, drops `x`, quarantines the rest. struct Rename; impl RemakeKind for Rename { fn name(&self) -> &'static str { "rename-test" } fn propose(&self, _spec: &JsonValue) -> Result { Ok(Prepared::default()) } fn build( &self, _old: &AutoCommit, _content: &JsonValue, _target: &mut AutoCommit, ) -> Result<(), StoreError> { Ok(()) } fn translate(&self, path: &[String], _translation: &JsonValue) -> Translation { match path.split_first() { Some((first, rest)) if first == "a" => { let mut to = vec!["b".to_owned()]; to.extend_from_slice(rest); Translation::To(to) } Some((first, _)) if first == "b" => Translation::Same, Some((first, _)) if first == "x" => Translation::Drop, _ => Translation::Quarantine("no rule".to_owned()), } } } fn path(parts: &[&str]) -> Vec { parts.iter().map(|part| (*part).to_owned()).collect() } #[test] fn compaction_keeps_paths() { assert_eq!( Translator::single(&Compact, JsonValue::Null).translate(&path(&["a", "b"])), Translation::Same ); assert!(Compact.propose(&JsonValue::Null).is_ok()); assert!(Compact.propose(&serde_json::json!({ "x": 1 })).is_err()); } #[test] fn steps_compose() { static RENAME: Rename = Rename; let mut translator = Translator::default(); translator.push(&RENAME, JsonValue::Null); translator.push(&Compact, JsonValue::Null); translator.push(&RENAME, JsonValue::Null); // b stays b assert_eq!( translator.translate(&path(&["a", "k"])), Translation::To(path(&["b", "k"])) ); assert_eq!(translator.translate(&path(&["x"])), Translation::Drop); assert!(matches!( translator.translate(&path(&["other"])), Translation::Quarantine(_) )); // Protocol data is never translated. assert_eq!( translator.translate(&path(&[META_ROOT, "forgotten", "pk"])), Translation::Same ); } }