summaryrefslogtreecommitdiffstats
path: root/src/remake/kind.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/remake/kind.rs')
-rw-r--r--src/remake/kind.rs230
1 files changed, 230 insertions, 0 deletions
diff --git a/src/remake/kind.rs b/src/remake/kind.rs
new file mode 100644
index 0000000..329fd09
--- /dev/null
+++ b/src/remake/kind.rs
@@ -0,0 +1,230 @@
+//! 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<String>),
+ /// 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<Prepared, String>;
+
+ /// 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<Prepared, String> {
+ 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(&current, 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<Prepared, String> {
+ 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<String> {
+ 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
+ );
+ }
+}