Skip to main content

macrame/graph/
edge.rs

1use crate::branch::BranchId;
2use crate::error::{DbError, Result};
3use crate::util::timestamp::{self, OPEN_SENTINEL};
4
5/// Edge assertion builder for assert / retire / re-assert lifecycle operations.
6///
7/// # `#[non_exhaustive]` since 0.14.8, and what that costs
8///
9/// [`branch`](Self::branch) is the first field added to this struct since it
10/// was written, and adding a public field to a struct with all-public fields is
11/// already a break: `EdgeAssertion { source, target, .. }` as a literal stops
12/// compiling. Taking `#[non_exhaustive]` in the same release converts a break
13/// that will recur into one that happens once — the builder
14/// ([`new`](Self::new) and the setters) is the documented path, is what every
15/// caller in this crate and its bindings uses, and keeps working untouched.
16/// [`EdgeBelief`](crate::temporal::EdgeBelief) took the same treatment at
17/// 0.14.5 for the same reason (D-222).
18#[derive(Debug, Clone, PartialEq)]
19#[non_exhaustive]
20pub struct EdgeAssertion {
21    pub source: String,
22    pub target: String,
23    pub edge_type: String,
24    pub valid_from: String,
25    pub valid_to: String,
26    pub weight: f64,
27    pub properties: String,
28    /// The lineage this assertion is made on, or `None` for the trunk (§15.4,
29    /// D-225).
30    ///
31    /// `None` and `Some(BranchId::main())` name the same lineage and are not
32    /// distinguished by the write, which is deliberate: `main` is a branch like
33    /// any other and a caller who spells it out should get exactly what a
34    /// caller who left it unset gets. What `None` buys is on the *cost* side —
35    /// the write path can take the pre-0.14.8 statement, with no branch
36    /// existence check and no second parameter, so a database that never forks
37    /// pays nothing for a column it cannot vary. See
38    /// [`Database::assert_edge`](crate::Database::assert_edge).
39    pub branch: Option<BranchId>,
40}
41
42impl EdgeAssertion {
43    pub fn new(
44        source: impl Into<String>,
45        target: impl Into<String>,
46        edge_type: impl Into<String>,
47    ) -> Self {
48        Self {
49            source: source.into(),
50            target: target.into(),
51            edge_type: edge_type.into(),
52            valid_from: String::new(),
53            valid_to: OPEN_SENTINEL.to_string(),
54            weight: 1.0,
55            properties: "{}".to_string(),
56            branch: None,
57        }
58    }
59
60    /// When the asserted fact starts being true (valid time, Doctrine II).
61    pub fn valid_from(mut self, ts: impl Into<String>) -> Self {
62        self.valid_from = ts.into();
63        self
64    }
65
66    /// When the asserted fact stops being true. Defaults to the open sentinel.
67    pub fn valid_to(mut self, ts: impl Into<String>) -> Self {
68        self.valid_to = ts.into();
69        self
70    }
71
72    pub fn weight(mut self, weight: f64) -> Self {
73        self.weight = weight;
74        self
75    }
76
77    pub fn properties(mut self, json: impl Into<String>) -> Self {
78        self.properties = json.into();
79        self
80    }
81
82    /// Assert this edge on `branch` rather than on the trunk (0.14.8, §15.4).
83    ///
84    /// The same name the read side takes
85    /// ([`TraversalBuilder::on_branch`](crate::graph::TraversalBuilder::on_branch)),
86    /// because it is the same question asked of the other half: *which lineage
87    /// is this about*. Until 0.14.8 only the read could ask it, and a caller who
88    /// forked and then asserted got a successful write **on the trunk** — the
89    /// gap [`Database::fork`](crate::Database::fork)'s rustdoc has named since
90    /// 0.14.7 and this closes.
91    ///
92    /// # This writes a row *beside* the ancestor's, never over it
93    ///
94    /// `links_current` is keyed `(source_id, target_id, edge_type, valid_from,
95    /// branch_id)`, so an assertion on a branch about an edge it inherited adds
96    /// the branch's own row and leaves the parent's untouched. That is the
97    /// whole storage cost of divergence, and it is what makes the parent's
98    /// history unchanged by anything a branch does —
99    /// [Doctrine III](../../docs/architecture/s0-s3-foundations.md#doctrine-iii)
100    /// is not a policy the write path enforces here, it is a shape the key
101    /// makes unrepresentable.
102    ///
103    /// The read resolves the two by nearest lineage
104    /// ([D-220](../../docs/architecture/s13-decision-register.md#d-220)), so the
105    /// branch sees its own and the trunk keeps seeing the trunk's.
106    pub fn on_branch(mut self, branch: BranchId) -> Self {
107        self.branch = Some(branch);
108        self
109    }
110
111    /// The lineage this assertion names, spelled out.
112    ///
113    /// One place that decides what `None` means, so the insert, the overlap
114    /// guard and the existence check cannot answer it three ways.
115    pub(crate) fn branch_name(&self) -> &str {
116        self.branch
117            .as_ref()
118            .map_or(crate::schema::ddl::MAIN_BRANCH, BranchId::as_str)
119    }
120
121    /// Check the assertion and put its timestamps in canonical form (D-029).
122    ///
123    /// Runs before the write reaches the actor, so a malformed edge type or a
124    /// second-precision timestamp comes back as a typed error rather than as an
125    /// engine `CHECK` failure from the other side of a channel — by which point
126    /// the caller has lost the context that would explain it.
127    pub fn normalized(mut self) -> Result<Self> {
128        // Both endpoints, because the log's entity key concatenates both and an
129        // ambiguity in either makes the row unattributable (D-061).
130        crate::util::ids::validate_id(&self.source)?;
131        crate::util::ids::validate_id(&self.target)?;
132        validate_edge_type(&self.edge_type)?;
133        self.valid_from = timestamp::normalize(&self.valid_from)?;
134        self.valid_to = timestamp::normalize(&self.valid_to)?;
135        Ok(self)
136    }
137}
138
139/// Edge types are `[A-Z0-9]+` (§4.1).
140///
141/// The constraint is not cosmetic: edge types are concatenated into
142/// `transaction_log.entity_id` with `|` separators, so a type containing a
143/// separator would corrupt the key that replay reads the log back by.
144///
145/// This comment used to also claim the constraint protected the traversal CTE,
146/// which spliced edge types in as quoted literals. It did not: this function
147/// runs from [`EdgeAssertion::normalized`] on the write path only, and
148/// [`super::TraversalBuilder::edge_types`] never called it. The CTE now binds
149/// them as parameters (D-039), so that half of the justification is gone rather
150/// than merely unenforced.
151pub fn validate_edge_type(edge_type: &str) -> Result<()> {
152    let ok = !edge_type.is_empty()
153        && edge_type
154            .bytes()
155            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit());
156    if ok {
157        Ok(())
158    } else {
159        Err(DbError::InvalidEdgeType(edge_type.to_string()))
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn edge_types_are_uppercase_alphanumeric() {
169        assert!(validate_edge_type("KNOWS").is_ok());
170        assert!(validate_edge_type("REL2").is_ok());
171
172        for bad in [
173            "",
174            "knows",
175            "KNOWS_WELL",
176            "KNOWS-WELL",
177            "A|B",
178            "O'BRIEN",
179            "ÉTAT",
180        ] {
181            assert!(
182                validate_edge_type(bad).is_err(),
183                "{bad:?} should be rejected"
184            );
185        }
186    }
187
188    #[test]
189    fn normalizing_widens_timestamps_and_rejects_bad_types() {
190        let e = EdgeAssertion::new("a", "b", "KNOWS")
191            .valid_from("2026-01-01T00:00:00Z")
192            .normalized()
193            .unwrap();
194        assert_eq!(e.valid_from, "2026-01-01T00:00:00.000000Z");
195        assert_eq!(e.valid_to, OPEN_SENTINEL);
196
197        assert!(EdgeAssertion::new("a", "b", "bad")
198            .valid_from("2026-01-01T00:00:00.000000Z")
199            .normalized()
200            .is_err());
201    }
202}