Skip to main content

graphforge_core/
uuid.rs

1//! UUID generation, deterministic derivation, and Arrow/Parquet serialisation helpers.
2//!
3//! All write paths (CREATE, MERGE, batch ingest, provenance) mint UUIDv7
4//! identifiers for new first-class objects.  Centralising generation here keeps
5//! identity consistent across crates and provides the
6//! [`FixedSizeBinary(16)`](to_bytes) conversions used by the storage layer.
7//!
8//! UUIDv7 is time-ordered (RFC 9562): the 48 most-significant bits are a
9//! Unix-millisecond timestamp, so identifiers minted later sort after earlier
10//! ones — useful for stable, roughly-chronological row ordering.
11
12pub use uuid::Uuid;
13
14/// Generate a new UUIDv7 stamped with the current time.
15#[must_use]
16pub fn new_v7() -> Uuid {
17    Uuid::now_v7()
18}
19
20/// Namespace UUID for GraphForge provenance lineage (#604): the stable root for
21/// content-addressed derived-fact handles. A fixed constant — never regenerate
22/// it, or existing lineage `child_uuid`s stop resolving.
23pub const PROVENANCE_NAMESPACE: Uuid = Uuid::from_bytes([
24    0x9f, 0x6c, 0x2a, 0x1e, 0x7b, 0x42, 0x4d, 0x88, 0xa3, 0x10, 0x5e, 0x0c, 0x91, 0x33, 0x77, 0xd2,
25]);
26
27/// Mint a name-based (UUIDv5, SHA-1) identifier under `namespace`. The same
28/// `(namespace, name)` always yields the same UUID, so it content-addresses a
29/// derived fact: re-running a query re-derives the identical `child_uuid`,
30/// letting lineage rows dedup/accumulate idempotently rather than growing
31/// unboundedly. (#604)
32#[must_use]
33pub fn new_v5(namespace: &Uuid, name: &[u8]) -> Uuid {
34    Uuid::new_v5(namespace, name)
35}
36
37/// Derive the portable-v1 imported generation from its caller-owned operation.
38/// The versioned name is a persisted compatibility contract.
39#[must_use]
40pub fn portable_import_generation(operation: &Uuid) -> Uuid {
41    new_v5(operation, b"graphforge-portable-import-generation/1")
42}
43
44/// Derive the portable-v2 imported generation in a separate versioned domain.
45#[must_use]
46pub fn portable_v2_import_generation(operation: &Uuid) -> Uuid {
47    new_v5(operation, b"graphforge-portable-v2-import-generation/1")
48}
49
50/// Derive the empty parent generation used by a fresh portable-v2 import.
51/// The versioned name is a persisted compatibility contract.
52#[must_use]
53pub fn portable_v2_bootstrap_generation(operation: &Uuid) -> Uuid {
54    new_v5(operation, b"graphforge-portable-v2-bootstrap-generation/1")
55}
56
57/// Derive the empty parent's transaction in a separate versioned domain.
58#[must_use]
59pub fn portable_v2_bootstrap_transaction(operation: &Uuid) -> Uuid {
60    new_v5(operation, b"graphforge-portable-v2-bootstrap-transaction/1")
61}
62
63/// Derive one composite delta operation using its original mutation index.
64/// The slash separates the versioned domain from the unambiguous decimal index.
65#[must_use]
66pub fn composite_delta_operation(operation: &Uuid, index: usize) -> Uuid {
67    new_v5(
68        operation,
69        format!("graphforge-composite-delta-operation/1/{index}").as_bytes(),
70    )
71}
72
73/// Derive the composite delta run from its transaction identity.
74#[must_use]
75pub fn composite_delta_run(transaction: &Uuid) -> Uuid {
76    new_v5(transaction, b"graphforge-composite-delta-run/1")
77}
78
79/// Derive a hub clone operation using the historical URL namespace and name.
80///
81/// `repository` must be the validated canonical `owner/repository` identity
82/// (whose slugs cannot contain `:`), and `immutable_version` the verified package
83/// version. The colon therefore separates the components unambiguously. Preserve
84/// this legacy domain: adding a new prefix would change existing retry identities.
85#[must_use]
86pub fn hub_clone_operation(repository: &str, immutable_version: &str) -> Uuid {
87    new_v5(
88        &Uuid::NAMESPACE_URL,
89        format!("{repository}:{immutable_version}").as_bytes(),
90    )
91}
92
93/// Derive a hub clone operation for one exact research Version of a snapshot.
94///
95/// A Project-package clone keeps [`hub_clone_operation`]. A research clone also
96/// binds the selected research `version_uuid` and its native identity digest, so
97/// clones of different Versions (a Branch head versus an immutable base) of one
98/// repository snapshot never share an operation, and therefore never share the
99/// generation and transaction identities derived from it.
100#[must_use]
101pub fn hub_research_clone_operation(
102    repository: &str,
103    immutable_version: &str,
104    version_uuid: &str,
105    identity_digest: &str,
106) -> Uuid {
107    new_v5(
108        &hub_clone_operation(repository, immutable_version),
109        format!("graphforge-hub-research-clone/1/{version_uuid}/{identity_digest}").as_bytes(),
110    )
111}
112
113/// Derive the default `gf publish` operation for one exact research Version
114/// of one exact Project state.
115///
116/// `repository` is the canonical `owner/repository`, `git_ref` the published
117/// Branch ref (absent when an exact Version is published without a Branch),
118/// `version_uuid` the published Version, and `project_content` the identity of
119/// the exported Project state (the committed generation's manifest digest).
120/// NUL cannot occur in any component, so the encoding is unambiguous.
121/// Publishing the same Version of an unchanged Project to the same ref again
122/// reuses the operation and replays its receipt; a changed Project gets a new
123/// operation.
124#[must_use]
125pub fn hub_publish_operation(
126    repository: &str,
127    git_ref: Option<&str>,
128    version_uuid: &str,
129    project_content: &str,
130) -> Uuid {
131    new_v5(
132        &Uuid::NAMESPACE_URL,
133        format!(
134            "graphforge-hub-publish/1\0{repository}\0{}\0{version_uuid}\0{project_content}",
135            git_ref.unwrap_or("")
136        )
137        .as_bytes(),
138    )
139}
140
141/// Convert a [`Uuid`] into its 16-byte big-endian form, suitable for an Arrow
142/// `FixedSizeBinary(16)` column.
143#[must_use]
144pub fn to_bytes(uuid: &Uuid) -> [u8; 16] {
145    *uuid.as_bytes()
146}
147
148/// Reconstruct a [`Uuid`] from its 16-byte big-endian form.
149#[must_use]
150pub fn from_bytes(bytes: &[u8; 16]) -> Uuid {
151    Uuid::from_bytes(*bytes)
152}
153
154/// Render a [`Uuid`] in canonical hyphenated form for display.
155#[must_use]
156pub fn to_string(uuid: &Uuid) -> String {
157    uuid.hyphenated().to_string()
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn uuids_are_unique() {
166        let mut seen = std::collections::HashSet::new();
167        for _ in 0..1000 {
168            assert!(seen.insert(new_v7()), "generated a duplicate UUID");
169        }
170        assert_eq!(seen.len(), 1000);
171    }
172
173    #[test]
174    fn uuids_are_time_monotone() {
175        // UUIDv7 embeds a millisecond timestamp in its high bits; successive
176        // values generated within the same process should be non-decreasing
177        // bitwise.  Comparing many in sequence keeps the test robust to
178        // multiple UUIDs landing in the same millisecond (the v7 counter then
179        // breaks the tie monotonically).
180        let mut prev = new_v7();
181        for _ in 0..1000 {
182            let next = new_v7();
183            assert!(next > prev, "UUIDv7 ordering violated: {next} !> {prev}");
184            prev = next;
185        }
186    }
187
188    #[test]
189    fn byte_round_trip_preserves_value() {
190        let original = new_v7();
191        let bytes = to_bytes(&original);
192        let restored = from_bytes(&bytes);
193        assert_eq!(original, restored);
194    }
195
196    #[test]
197    fn v5_is_namespace_and_name_deterministic() {
198        let namespace = Uuid::from_bytes([7; 16]);
199        let first = new_v5(&namespace, b"graphforge");
200        assert_eq!(first, new_v5(&namespace, b"graphforge"));
201        assert_ne!(first, new_v5(&namespace, b"GraphForge"));
202        assert_eq!(first.get_version_num(), 5);
203    }
204
205    #[test]
206    fn persisted_derivation_golden_vectors() {
207        // Frozen from the original namespace/name pairs, independently calculated
208        // with Python uuid.uuid5. Compare bytes to protect persisted identities.
209        let operation = Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap();
210        let version_a = format!("sha256:{}", "a".repeat(64));
211        let version_b = format!("sha256:{}", "b".repeat(64));
212        let vectors = [
213            (
214                portable_import_generation(&operation),
215                "02abd337-e93c-56a3-bf8f-dadbb5b1e8d5",
216            ),
217            (
218                portable_v2_import_generation(&operation),
219                "167a58d3-52f2-533e-b3c4-01d743d7537d",
220            ),
221            (
222                portable_v2_bootstrap_generation(&operation),
223                "d633da3b-0a2e-543e-bcdd-d383b3c52794",
224            ),
225            (
226                portable_v2_bootstrap_transaction(&operation),
227                "7f6e6309-9103-5c77-837a-0efffec19b70",
228            ),
229            (
230                composite_delta_operation(&operation, 0),
231                "bf613aba-38a4-5ae7-8982-1675caa13e7c",
232            ),
233            (
234                composite_delta_operation(&operation, 1),
235                "dcffd77e-a011-5480-8fb3-8493d22ff8be",
236            ),
237            (
238                composite_delta_operation(&operation, 10),
239                "de381822-c256-5bf9-904f-25692a6b6c7e",
240            ),
241            (
242                composite_delta_run(&operation),
243                "d230eba6-b8a4-549e-b032-888112a9fc62",
244            ),
245            (
246                hub_clone_operation("curatelabs/demo", &version_a),
247                "e6caa879-f226-55fc-9d67-41c15b8f9658",
248            ),
249            (
250                hub_clone_operation("curatelabs/demo", &version_b),
251                "db3c36b4-97ee-57bb-ac63-995c79fb577b",
252            ),
253            (
254                hub_clone_operation("curatelabs/other", &version_a),
255                "29ea96be-d350-5026-a85b-32cbf5b835cc",
256            ),
257            (
258                hub_research_clone_operation(
259                    "curatelabs/demo",
260                    &version_a,
261                    "01900000-0000-7000-8000-000000000021",
262                    &format!("sha256:{}", "c".repeat(64)),
263                ),
264                "a53b2fd9-7de0-5826-9fc3-cbd8f7b07c4c",
265            ),
266            (
267                hub_research_clone_operation(
268                    "curatelabs/demo",
269                    &version_a,
270                    "01900000-0000-7000-8000-000000000022",
271                    &format!("sha256:{}", "c".repeat(64)),
272                ),
273                "c4bb0970-3473-5822-a82a-516833d8fde8",
274            ),
275            (
276                hub_publish_operation(
277                    "curatelabs/demo",
278                    Some("main"),
279                    "01900000-0000-7000-8000-000000000021",
280                    &format!("sha256:{}", "e".repeat(64)),
281                ),
282                "d93f3756-aea3-568b-b2b6-984835743c81",
283            ),
284            (
285                hub_publish_operation(
286                    "curatelabs/demo",
287                    None,
288                    "01900000-0000-7000-8000-000000000021",
289                    &format!("sha256:{}", "e".repeat(64)),
290                ),
291                "b8c6df3d-28dc-589b-bf90-2378b9a388fc",
292            ),
293            (
294                hub_publish_operation(
295                    "curatelabs/demo",
296                    Some("main"),
297                    "01900000-0000-7000-8000-000000000022",
298                    &format!("sha256:{}", "e".repeat(64)),
299                ),
300                "7f331a69-7ea4-5ceb-9280-9f44b8db5a2d",
301            ),
302            (
303                hub_publish_operation(
304                    "curatelabs/demo",
305                    Some("main"),
306                    "01900000-0000-7000-8000-000000000021",
307                    &format!("sha256:{}", "f".repeat(64)),
308                ),
309                "e0a6d8fd-b812-5603-ba77-b694e25648c9",
310            ),
311            (
312                new_v5(&PROVENANCE_NAMESPACE, b"graphforge"),
313                "59a64d29-3fe8-5c2c-ad21-1f285e53d758",
314            ),
315        ];
316        let mut unique = std::collections::HashSet::new();
317        for (actual, expected) in vectors {
318            assert_eq!(
319                actual.as_bytes(),
320                Uuid::parse_str(expected).unwrap().as_bytes()
321            );
322            assert!(unique.insert(actual), "derivation domains collided");
323        }
324        let other_operation = Uuid::from_bytes([7; 16]);
325        assert_ne!(
326            portable_import_generation(&operation),
327            portable_import_generation(&other_operation)
328        );
329        assert_ne!(
330            portable_v2_import_generation(&operation),
331            portable_v2_import_generation(&other_operation)
332        );
333        assert_ne!(
334            portable_v2_bootstrap_generation(&operation),
335            portable_v2_bootstrap_generation(&other_operation)
336        );
337        assert_ne!(
338            portable_v2_bootstrap_transaction(&operation),
339            portable_v2_bootstrap_transaction(&other_operation)
340        );
341        assert_ne!(
342            composite_delta_operation(&operation, 0),
343            composite_delta_operation(&other_operation, 0)
344        );
345        assert_ne!(
346            composite_delta_run(&operation),
347            composite_delta_run(&other_operation)
348        );
349    }
350
351    #[test]
352    fn string_matches_rfc_format() {
353        let s = to_string(&new_v7());
354        // 8-4-4-4-12 hyphenated form, 36 chars total.
355        assert_eq!(s.len(), 36);
356        let groups: Vec<&str> = s.split('-').collect();
357        assert_eq!(groups.len(), 5);
358        assert_eq!(
359            groups.iter().map(|g| g.len()).collect::<Vec<_>>(),
360            vec![8, 4, 4, 4, 12]
361        );
362        assert!(s.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
363        // Version nibble (first char of the 3rd group) must be '7'.
364        assert_eq!(groups[2].chars().next(), Some('7'));
365    }
366}