Skip to main content

macrame/util/
ids.rs

1//! Identifiers: what the crate requires of them, and what it merely offers.
2//!
3//! # The decision (D-061, defects AD and J)
4//!
5//! **Concept ids are opaque, with two reserved characters.** They are caller
6//! data, they are not required to be ULIDs, and the crate stores them as it
7//! receives them. What it does require is that an id contain neither `|` nor
8//! `/`, because two of the crate's own encodings use those bytes as delimiters
9//! and are ambiguous without them reserved.
10//!
11//! Both of the options the plan offered failed on inspection, and the way they
12//! failed is what produced this one.
13//!
14//! **"Wire `validate_id` into the write path" would have meant requiring
15//! ULIDs.** That is not a tightening of the current contract, it is a different
16//! contract: every test in the suite uses ids like `a`, `SRC` and `n000`, and so,
17//! presumably, does every caller. Three modules *assumed* ULIDs; nothing ever
18//! *required* them, and the assumption was wrong rather than merely unenforced.
19//!
20//! **"Declare ids fully opaque" would have left two encodings ambiguous.** It is
21//! stated as needing only a width-independent cycle check, and that is the
22//! smaller half. The other half is `transaction_log.entity_id`, which a link
23//! writes as `source|target|type|valid_from`. With `|` legal inside a component,
24//! two genuinely different links can produce one key — so the log cannot say
25//! which relationship a row belongs to, and the fold partition that closed defect
26//! W does not help, because both rows are links.
27//!
28//! So the constraint is the delimiters and nothing else. It is narrow enough to
29//! leave every existing id valid, and it turns three assumptions into one
30//! enforced property:
31//!
32//! - `edge_key` / `trg_links_log_insert` — the `|` join is now unambiguous.
33//! - The traversal CTE's cycle check — `/`-delimited and no longer dependent on
34//!   every id being the same width. See `TraversalBuilder::build_sql`.
35//! - Defect W's collision — a concept id can no longer *be* a link key, so the
36//!   conflation is unreachable rather than merely harmless. The partition fix
37//!   stays as defence in depth.
38//!
39//! Rejected: percent-encoding the components instead (moves the cost to every
40//! read and makes the log unreadable by eye, to buy two characters back);
41//! choosing delimiters no id would plausibly contain (the same bet, made
42//! silently — "plausibly" is what an assumption is).
43
44use ulid::Ulid;
45
46use crate::error::{DbError, Result};
47
48/// Bytes an identifier may not contain, and where each is load-bearing.
49///
50/// `|` joins a link's entity key in `transaction_log`. Structural, not
51/// cosmetic — a collision produces a wrong answer with no error.
52///
53/// **`/` was reserved here through 0.5.6 and is free again as of 0.6.0
54/// (T0.1, D-076).** It existed for exactly one reason: the traversal CTE
55/// carried a `path` column of visited ids, delimited by `/`, and D-061 reserved
56/// the character so the cycle check could match a whole element rather than a
57/// substring once ids became variable-length. T0.1 deleted the path column —
58/// the walk dedupes on entry instead — so nothing in the crate delimits with
59/// `/` any more, and continuing to refuse it would be a constraint kept for a
60/// mechanism that no longer exists.
61///
62/// Relaxing is safe in the direction that matters: ids previously refused are
63/// now accepted, so no stored id becomes invalid and no migration is implied.
64/// Adding a character back later would not be safe, which is why this list is
65/// worth keeping short.
66pub const RESERVED_ID_CHARS: [char; 1] = ['|'];
67
68/// Generate a new Crockford base32 26-character ULID string.
69///
70/// **Offered, not required.** Callers with no identifier scheme of their own
71/// should use this: ULIDs are sortable, collision-resistant, and contain neither
72/// reserved character by construction. Callers who have their own ids keep them,
73/// subject only to [`validate_id`].
74pub fn generate_id() -> String {
75    Ulid::new().to_string()
76}
77
78/// Whether `id` is a ULID. Not a requirement — see [`generate_id`].
79pub fn is_ulid(id: &str) -> bool {
80    Ulid::from_string(id).is_ok()
81}
82
83/// Refuse an identifier the crate's own encodings cannot represent (D-061).
84///
85/// Called from `ConceptUpsert::normalized` and `EdgeAssertion::normalized`, so
86/// a bad id is refused at the boundary with a typed error rather than becoming
87/// an ambiguous log key three layers down.
88///
89/// **The error type is the fix for defect J.** This used to return
90/// `DbError::NotFound(id)` for a malformed identifier, which says the opposite
91/// of what happened — the id was not looked up and not missing; it was refused.
92/// A caller matching on `NotFound` to decide whether to create the thing would
93/// have been sent to create it again, with the same id, forever.
94pub fn validate_id(id: &str) -> Result<()> {
95    if id.is_empty() {
96        return Err(DbError::InvalidId {
97            id: id.to_string(),
98            reason: "identifier is empty".to_string(),
99        });
100    }
101    if let Some(c) = id.chars().find(|c| RESERVED_ID_CHARS.contains(c)) {
102        return Err(DbError::InvalidId {
103            id: id.to_string(),
104            reason: format!(
105                "identifier contains the reserved character {c:?}; \
106                 {RESERVED_ID_CHARS:?} delimits the transaction-log entity key, \
107                 so an id carrying one is ambiguous"
108            ),
109        });
110    }
111    Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn ordinary_identifiers_are_accepted() {
120        // Every shape the suite and, by implication, callers actually use.
121        for id in ["a", "SRC", "n000", "node_100", "c1", &generate_id()] {
122            validate_id(id).unwrap_or_else(|e| panic!("{id:?} must stay valid: {e}"));
123        }
124    }
125
126    /// `/` is accepted again (T0.1, D-076).
127    ///
128    /// It was reserved solely for the traversal CTE's path delimiter, and the
129    /// path column is gone. Asserted rather than assumed, because a constraint
130    /// that outlives its mechanism is invisible until someone hits it — and
131    /// because the direction matters: this test failing means the crate has
132    /// started refusing ids it accepted, which is a breaking change to callers.
133    #[test]
134    fn a_slash_is_no_longer_reserved() {
135        for id in ["a/b", "urn:x/y/z", "2026/01/01"] {
136            validate_id(id).unwrap_or_else(|e| panic!("{id:?} must be accepted now: {e}"));
137        }
138    }
139
140    #[test]
141    fn a_reserved_character_is_refused_by_name() {
142        for id in ["a|b", "a|b|KNOWS|2026-01-01T00:00:00.000000Z"] {
143            let err = validate_id(id).unwrap_err();
144            assert!(
145                matches!(err, DbError::InvalidId { .. }),
146                "{id:?} gave {err:?}"
147            );
148        }
149    }
150
151    /// Defect J: a refused identifier is not a missing one.
152    #[test]
153    fn a_refusal_is_not_a_not_found() {
154        assert!(!matches!(
155            validate_id("a|b").unwrap_err(),
156            DbError::NotFound(_)
157        ));
158    }
159
160    /// A ULID must satisfy the constraint, or `generate_id` would hand callers
161    /// ids the write path rejects.
162    #[test]
163    fn generated_ids_are_always_valid() {
164        for _ in 0..64 {
165            let id = generate_id();
166            assert!(is_ulid(&id));
167            validate_id(&id).unwrap();
168        }
169    }
170}