use crate::{Attributes, NetworkId, NodeId, ResourceId, TimeStamp, attributes_value_equal}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Node { pub(crate) implementation: Implementation, pub(crate) inputs: Vec, pub(crate) attributes: Attributes, pub(crate) network: NetworkId, } impl Node { pub fn implementation(&self) -> &Implementation { &self.implementation } pub fn inputs(&self) -> &[InputSlot] { &self.inputs } pub fn attributes(&self) -> &Attributes { &self.attributes } pub fn network(&self) -> NetworkId { self.network } /// True if both nodes agree on every value-bearing field, ignoring slot/attribute timestamps. pub fn value_equal(&self, other: &Self) -> bool { if self.implementation != other.implementation || self.network != other.network { return false; } if self.inputs.len() != other.inputs.len() { return false; } if !self .inputs .iter() .zip(&other.inputs) .all(|(a, b)| a.input == b.input && attributes_value_equal(&a.attributes, &b.attributes)) { return false; } attributes_value_equal(&self.attributes, &other.attributes) } #[cfg(test)] pub(crate) fn dummy() -> Self { Self { implementation: Implementation::ProtoNode(ResourceId::new()), inputs: vec![], attributes: Attributes::new(), network: crate::ROOT_NETWORK, } } } /// One positional input. The timestamp drives LWW on concurrent `ChangeNodeInput` ops targeting /// the same `(node_id, input_idx)`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct InputSlot { pub input: NodeInput, pub timestamp: TimeStamp, pub attributes: Attributes, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum NodeInput { Node { id: NodeId, index: u32, }, Value { value: serde_json::Value, exposed: bool, }, Scope(Cow<'static, str>), Import { index: u32, }, /// Marker; the `DocumentNodeMetadata` lives in `inputs_attributes`. Reflection, Other, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Implementation { /// References a proto-node declaration resource (see [`ProtoNode`]); the binding to content lives /// in `Registry.resources` like any other resource. ProtoNode(ResourceId), Network(NetworkId), } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Network { pub exports: Vec, /// Per-network `ui::*` state (navigation, previewing). Separate from `Node.attributes` so /// view-state edits LWW independently. pub attributes: Attributes, } impl Network { /// True if both networks agree on every value-bearing field, ignoring slot/attribute timestamps. pub fn value_equal(&self, other: &Self) -> bool { // Compare slot targets index-by-index, treating out-of-range slots as `None`. A `SetExport(None)` // truncation leaves a trailing empty slot (a tombstone in the CRDT state) that is value-equal to // the slot being absent, so trailing `None`s must not count as drift. Mirrors `compute_deltas` // (emits nothing for them) and `to_runtime` (drops them). let max_len = self.exports.len().max(other.exports.len()); for slot_idx in 0..max_len { let self_target = self.exports.get(slot_idx).and_then(|slot| slot.target.as_ref()); let other_target = other.exports.get(slot_idx).and_then(|slot| slot.target.as_ref()); if self_target != other_target { return false; } } attributes_value_equal(&self.attributes, &other.attributes) } } /// One positional export slot. `target == None` marks an empty/removed slot. Timestamp drives LWW /// on concurrent `SetExport` ops. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportSlot { pub target: Option, pub timestamp: TimeStamp, } /// Content of a proto-node declaration. Stored as a content-addressed resource (serialized bytes /// keyed by `ResourceHash`, held by the `Gdd` byte store) and referenced from /// `Implementation::ProtoNode(ResourceId)`. `document-graph-storage` itself only holds the reference. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ProtoNode { pub identifier: String, pub attributes: Attributes, } #[cfg(test)] mod tests { use super::*; use crate::TimeStamp; fn target_slot(node_id: u64) -> ExportSlot { ExportSlot { target: Some(NodeInput::Node { id: NodeId(node_id), index: 0 }), timestamp: TimeStamp::ORIGIN, } } fn empty_slot() -> ExportSlot { ExportSlot { target: None, timestamp: TimeStamp { counter: 5, peer: crate::PeerId(1) }, } } /// A `SetExport(None)` truncation leaves a trailing empty slot. Such a network is value-equal to /// the same network without that slot, so the soak oracle doesn't false-report drift. #[test] fn trailing_empty_export_slot_is_value_equal() { let compact = Network { exports: vec![target_slot(1), target_slot(2)], ..Default::default() }; let with_trailing_empty = Network { exports: vec![target_slot(1), target_slot(2), empty_slot()], ..Default::default() }; assert!(compact.value_equal(&with_trailing_empty)); assert!(with_trailing_empty.value_equal(&compact)); } /// A `None` slot *between* live targets is a real value difference (a hole), not a trailing /// tombstone, so it must still count as drift. #[test] fn interior_empty_export_slot_is_not_value_equal() { let dense = Network { exports: vec![target_slot(1), target_slot(2)], ..Default::default() }; let with_hole = Network { exports: vec![target_slot(1), empty_slot(), target_slot(2)], ..Default::default() }; assert!(!dense.value_equal(&with_hole)); } }