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}