Skip to main content

macrame/graph/
edge.rs

1use crate::error::{DbError, Result};
2use crate::util::timestamp::{self, OPEN_SENTINEL};
3
4/// Edge assertion builder for assert / retire / re-assert lifecycle operations.
5#[derive(Debug, Clone, PartialEq)]
6pub struct EdgeAssertion {
7    pub source: String,
8    pub target: String,
9    pub edge_type: String,
10    pub valid_from: String,
11    pub valid_to: String,
12    pub weight: f64,
13    pub properties: String,
14}
15
16impl EdgeAssertion {
17    pub fn new(
18        source: impl Into<String>,
19        target: impl Into<String>,
20        edge_type: impl Into<String>,
21    ) -> Self {
22        Self {
23            source: source.into(),
24            target: target.into(),
25            edge_type: edge_type.into(),
26            valid_from: String::new(),
27            valid_to: OPEN_SENTINEL.to_string(),
28            weight: 1.0,
29            properties: "{}".to_string(),
30        }
31    }
32
33    /// When the asserted fact starts being true (valid time, Doctrine II).
34    pub fn valid_from(mut self, ts: impl Into<String>) -> Self {
35        self.valid_from = ts.into();
36        self
37    }
38
39    /// When the asserted fact stops being true. Defaults to the open sentinel.
40    pub fn valid_to(mut self, ts: impl Into<String>) -> Self {
41        self.valid_to = ts.into();
42        self
43    }
44
45    pub fn weight(mut self, weight: f64) -> Self {
46        self.weight = weight;
47        self
48    }
49
50    pub fn properties(mut self, json: impl Into<String>) -> Self {
51        self.properties = json.into();
52        self
53    }
54
55    /// Check the assertion and put its timestamps in canonical form (D-029).
56    ///
57    /// Runs before the write reaches the actor, so a malformed edge type or a
58    /// second-precision timestamp comes back as a typed error rather than as an
59    /// engine `CHECK` failure from the other side of a channel — by which point
60    /// the caller has lost the context that would explain it.
61    pub fn normalized(mut self) -> Result<Self> {
62        // Both endpoints, because the log's entity key concatenates both and an
63        // ambiguity in either makes the row unattributable (D-061).
64        crate::util::ids::validate_id(&self.source)?;
65        crate::util::ids::validate_id(&self.target)?;
66        validate_edge_type(&self.edge_type)?;
67        self.valid_from = timestamp::normalize(&self.valid_from)?;
68        self.valid_to = timestamp::normalize(&self.valid_to)?;
69        Ok(self)
70    }
71}
72
73/// Edge types are `[A-Z0-9]+` (§4.1).
74///
75/// The constraint is not cosmetic: edge types are concatenated into
76/// `transaction_log.entity_id` with `|` separators, so a type containing a
77/// separator would corrupt the key that replay reads the log back by.
78///
79/// This comment used to also claim the constraint protected the traversal CTE,
80/// which spliced edge types in as quoted literals. It did not: this function
81/// runs from [`EdgeAssertion::normalized`] on the write path only, and
82/// [`super::TraversalBuilder::edge_types`] never called it. The CTE now binds
83/// them as parameters (D-039), so that half of the justification is gone rather
84/// than merely unenforced.
85pub fn validate_edge_type(edge_type: &str) -> Result<()> {
86    let ok = !edge_type.is_empty()
87        && edge_type
88            .bytes()
89            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit());
90    if ok {
91        Ok(())
92    } else {
93        Err(DbError::InvalidEdgeType(edge_type.to_string()))
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn edge_types_are_uppercase_alphanumeric() {
103        assert!(validate_edge_type("KNOWS").is_ok());
104        assert!(validate_edge_type("REL2").is_ok());
105
106        for bad in [
107            "",
108            "knows",
109            "KNOWS_WELL",
110            "KNOWS-WELL",
111            "A|B",
112            "O'BRIEN",
113            "ÉTAT",
114        ] {
115            assert!(
116                validate_edge_type(bad).is_err(),
117                "{bad:?} should be rejected"
118            );
119        }
120    }
121
122    #[test]
123    fn normalizing_widens_timestamps_and_rejects_bad_types() {
124        let e = EdgeAssertion::new("a", "b", "KNOWS")
125            .valid_from("2026-01-01T00:00:00Z")
126            .normalized()
127            .unwrap();
128        assert_eq!(e.valid_from, "2026-01-01T00:00:00.000000Z");
129        assert_eq!(e.valid_to, OPEN_SENTINEL);
130
131        assert!(EdgeAssertion::new("a", "b", "bad")
132            .valid_from("2026-01-01T00:00:00.000000Z")
133            .normalized()
134            .is_err());
135    }
136}