1use std::collections::BTreeMap;
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use ulid::Ulid;
23
24use crate::core::schema::{DataType, EdgeTypeMeta, LabelMeta};
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
31#[serde(transparent)]
32pub struct ForkId(pub Ulid);
33
34impl ForkId {
35 #[must_use]
37 pub fn new() -> Self {
38 Self(Ulid::new())
39 }
40
41 pub fn parse(s: &str) -> Result<Self, ulid::DecodeError> {
47 Ulid::from_string(s).map(Self)
48 }
49}
50
51impl Default for ForkId {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl std::fmt::Display for ForkId {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 self.0.fmt(f)
60 }
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70#[non_exhaustive]
71pub enum ForkStatus {
72 Pending,
74 Active,
76 Tombstoned,
78}
79
80#[derive(Clone, Debug, Serialize, Deserialize)]
86pub struct ForkInfo {
87 pub id: ForkId,
89
90 pub name: String,
92
93 #[serde(default)]
96 pub parent_fork_id: Option<ForkId>,
97
98 pub parent_snapshot_id: String,
100
101 pub created_at: DateTime<Utc>,
103
104 #[serde(default)]
107 pub ttl_expires_at: Option<DateTime<Utc>>,
108
109 pub schema_version_at_creation: u32,
113
114 pub datasets: BTreeMap<String, String>,
118
119 pub status: ForkStatus,
121}
122
123impl ForkInfo {
124 #[must_use]
126 pub fn new_pending(
127 id: ForkId,
128 name: impl Into<String>,
129 parent_snapshot_id: impl Into<String>,
130 schema_version: u32,
131 ) -> Self {
132 Self {
133 id,
134 name: name.into(),
135 parent_fork_id: None,
136 parent_snapshot_id: parent_snapshot_id.into(),
137 created_at: Utc::now(),
138 ttl_expires_at: None,
139 schema_version_at_creation: schema_version,
140 datasets: BTreeMap::new(),
141 status: ForkStatus::Pending,
142 }
143 }
144}
145
146#[derive(Clone, Debug, Serialize, Deserialize)]
151pub struct PropertyAddition {
152 pub owner: String,
154 pub owner_kind: PropertyOwnerKind,
156 pub property: String,
158 pub data_type: DataType,
160 pub nullable: bool,
162}
163
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
166#[serde(rename_all = "lowercase")]
167pub enum PropertyOwnerKind {
168 Label,
170 EdgeType,
172}
173
174#[derive(Clone, Debug, Default, Serialize, Deserialize)]
181pub struct SchemaDelta {
182 #[serde(default)]
184 pub added_labels: Vec<(String, LabelMeta)>,
185
186 #[serde(default)]
188 pub added_edge_types: Vec<(String, EdgeTypeMeta)>,
189
190 #[serde(default)]
192 pub added_properties: Vec<PropertyAddition>,
193}
194
195impl SchemaDelta {
196 #[must_use]
198 pub fn empty() -> Self {
199 Self::default()
200 }
201
202 #[must_use]
204 pub fn is_empty(&self) -> bool {
205 self.added_labels.is_empty()
206 && self.added_edge_types.is_empty()
207 && self.added_properties.is_empty()
208 }
209
210 #[must_use]
223 pub fn merge_atop(&self, base: &SchemaDelta) -> SchemaDelta {
224 use std::collections::BTreeMap;
225
226 let mut labels: BTreeMap<String, LabelMeta> = BTreeMap::new();
228 for (name, meta) in &base.added_labels {
229 labels.insert(name.clone(), meta.clone());
230 }
231 for (name, meta) in &self.added_labels {
232 labels.insert(name.clone(), meta.clone());
233 }
234
235 let mut edge_types: BTreeMap<String, EdgeTypeMeta> = BTreeMap::new();
236 for (name, meta) in &base.added_edge_types {
237 edge_types.insert(name.clone(), meta.clone());
238 }
239 for (name, meta) in &self.added_edge_types {
240 edge_types.insert(name.clone(), meta.clone());
241 }
242
243 let mut properties: BTreeMap<(String, String), PropertyAddition> = BTreeMap::new();
244 for add in &base.added_properties {
245 properties.insert((add.owner.clone(), add.property.clone()), add.clone());
246 }
247 for add in &self.added_properties {
248 properties.insert((add.owner.clone(), add.property.clone()), add.clone());
249 }
250
251 SchemaDelta {
252 added_labels: labels.into_iter().collect(),
253 added_edge_types: edge_types.into_iter().collect(),
254 added_properties: properties.into_values().collect(),
255 }
256 }
257}
258
259#[derive(Clone, Debug, Default, Serialize, Deserialize)]
264pub struct ForkRegistryFile {
265 #[serde(default)]
267 pub forks: BTreeMap<String, ForkInfo>,
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn fork_id_roundtrip() {
276 let id = ForkId::new();
277 let s = id.to_string();
278 let parsed = ForkId::parse(&s).unwrap();
279 assert_eq!(id, parsed);
280 }
281
282 #[test]
283 fn fork_info_serde_roundtrip() {
284 let info = ForkInfo::new_pending(ForkId::new(), "scenario_1", "snap-abc", 17);
285 let json = serde_json::to_string(&info).unwrap();
286 let parsed: ForkInfo = serde_json::from_str(&json).unwrap();
287 assert_eq!(parsed.id, info.id);
288 assert_eq!(parsed.name, "scenario_1");
289 assert_eq!(parsed.parent_snapshot_id, "snap-abc");
290 assert_eq!(parsed.schema_version_at_creation, 17);
291 assert_eq!(parsed.status, ForkStatus::Pending);
292 assert!(parsed.datasets.is_empty());
293 assert!(parsed.parent_fork_id.is_none());
294 assert!(parsed.ttl_expires_at.is_none());
295 }
296
297 #[test]
298 fn registry_file_default_empty() {
299 let file = ForkRegistryFile::default();
300 let json = serde_json::to_string(&file).unwrap();
301 let parsed: ForkRegistryFile = serde_json::from_str(&json).unwrap();
302 assert!(parsed.forks.is_empty());
303 }
304
305 #[test]
306 fn schema_delta_default_is_empty() {
307 let d = SchemaDelta::default();
308 assert!(d.is_empty());
309 }
310
311 fn label_meta(id: u16) -> LabelMeta {
312 use crate::core::schema::SchemaElementState;
313 LabelMeta {
314 id,
315 created_at: chrono::Utc::now(),
316 state: SchemaElementState::Active,
317 description: None,
318 }
319 }
320
321 fn edge_type_meta(id: u32) -> EdgeTypeMeta {
322 use crate::core::schema::SchemaElementState;
323 EdgeTypeMeta {
324 id,
325 src_labels: vec!["A".into()],
326 dst_labels: vec!["A".into()],
327 state: SchemaElementState::Active,
328 description: None,
329 }
330 }
331
332 #[test]
333 fn merge_atop_unions_disjoint_labels_and_edge_types() {
334 let base = SchemaDelta {
335 added_labels: vec![("A".into(), label_meta(1))],
336 added_edge_types: vec![("E1".into(), edge_type_meta(10))],
337 ..Default::default()
338 };
339 let top = SchemaDelta {
340 added_labels: vec![("B".into(), label_meta(2))],
341 added_edge_types: vec![("E2".into(), edge_type_meta(20))],
342 ..Default::default()
343 };
344 let merged = top.merge_atop(&base);
345 let label_names: Vec<&str> = merged
346 .added_labels
347 .iter()
348 .map(|(n, _)| n.as_str())
349 .collect();
350 assert!(label_names.contains(&"A") && label_names.contains(&"B"));
351 let edge_names: Vec<&str> = merged
352 .added_edge_types
353 .iter()
354 .map(|(n, _)| n.as_str())
355 .collect();
356 assert!(edge_names.contains(&"E1") && edge_names.contains(&"E2"));
357 }
358
359 #[test]
360 fn merge_atop_self_wins_on_collision() {
361 let base = SchemaDelta {
362 added_labels: vec![("A".into(), label_meta(100))],
363 ..Default::default()
364 };
365 let top = SchemaDelta {
366 added_labels: vec![("A".into(), label_meta(200))],
367 ..Default::default()
368 };
369 let merged = top.merge_atop(&base);
370 assert_eq!(merged.added_labels.len(), 1);
371 assert_eq!(merged.added_labels[0].1.id, 200, "self must win");
372 }
373
374 #[test]
375 fn merge_atop_empty_base_is_self() {
376 let top = SchemaDelta {
377 added_labels: vec![("A".into(), label_meta(1))],
378 ..Default::default()
379 };
380 let merged = top.merge_atop(&SchemaDelta::empty());
381 assert_eq!(merged.added_labels.len(), 1);
382 assert_eq!(merged.added_labels[0].0, "A");
383 }
384
385 #[test]
386 fn merge_atop_empty_self_is_base() {
387 let base = SchemaDelta {
388 added_labels: vec![("A".into(), label_meta(1))],
389 ..Default::default()
390 };
391 let merged = SchemaDelta::empty().merge_atop(&base);
392 assert_eq!(merged.added_labels.len(), 1);
393 assert_eq!(merged.added_labels[0].0, "A");
394 }
395
396 #[test]
397 fn merge_atop_dedupes_properties_by_owner_and_name() {
398 let base_add = PropertyAddition {
399 owner: "Person".into(),
400 owner_kind: PropertyOwnerKind::Label,
401 property: "age".into(),
402 data_type: DataType::Int64,
403 nullable: true,
404 };
405 let top_add = PropertyAddition {
406 owner: "Person".into(),
407 owner_kind: PropertyOwnerKind::Label,
408 property: "age".into(),
409 data_type: DataType::String, nullable: false,
411 };
412 let base = SchemaDelta {
413 added_properties: vec![base_add],
414 ..Default::default()
415 };
416 let top = SchemaDelta {
417 added_properties: vec![top_add],
418 ..Default::default()
419 };
420 let merged = top.merge_atop(&base);
421 assert_eq!(merged.added_properties.len(), 1);
422 assert!(matches!(
423 merged.added_properties[0].data_type,
424 DataType::String
425 ));
426 assert!(!merged.added_properties[0].nullable);
427 }
428}