Files
Graphite/document/graph-storage/src/model.rs
Dennis Kobert b56af15e5e Add graph-storage crate (#4198)
* Add graph-storage crate

* Split graph-storage lib.rs into smaller modules

* Make graph_craft/core_types dependency optional

* Fix CRDT correctness issues flagged in graph-storage PR review

* Treat trailing empty export slots as value-equal in Network::value_equal

* Address graph-storage review: parameterize CrdtError, reject hot-log corruption, drop LamportClock Default

* Address graph-storage review: fix root-delta history walk, validate cross-network refs, make compute_deltas deterministic, harden Priority, index nodes by network

* Address graph-storage review: sort sources on deserialize, treat inputs_attributes length as structural, dedupe demo-artwork loader

* Add opt-in Delta rev validation, dedup resource sources on deserialize, fix doc grammar

* Persist scope injections through storage; harden gesture-end and embedded-source checks

* Review

* Review

* Review

* Use new types for NodeId and NetworkId

* Adress minor review comments

---------

Co-authored-by: Timon <me@timon.zip>
2026-06-13 16:47:04 +00:00

190 lines
5.5 KiB
Rust

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<InputSlot>,
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<ExportSlot>,
/// 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<NodeInput>,
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)`. `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));
}
}