Skip to main content

solti_model/resource/
metadata.rs

1//! # Object metadata.
2//!
3//! [`ObjectMeta`] tracks identity, versioning (`resource_version`), and timestamps.
4
5use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9use crate::TaskId;
10
11/// Resource metadata.
12///
13/// Identity (`id`), optimistic-concurrency counter (`resource_version`), and lifecycle timestamps.
14/// Slot and labels live in [`crate::TaskSpec`] as the single source of truth.
15///
16/// ## Also
17///
18/// - [`Task`](crate::Task) aggregate that embeds `ObjectMeta`.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct ObjectMeta {
22    /// Unique task identifier.
23    pub id: TaskId,
24    /// Incremented on any change to the resource (spec or status).
25    pub resource_version: u64,
26    /// When the resource was created.
27    #[serde(with = "time_serde")]
28    pub created_at: SystemTime,
29    /// When the resource was last updated.
30    #[serde(with = "time_serde")]
31    pub updated_at: SystemTime,
32}
33
34impl ObjectMeta {
35    /// Create metadata for a new resource.
36    pub fn new(id: TaskId) -> Self {
37        let now = SystemTime::now();
38
39        Self {
40            id,
41            resource_version: 1,
42            created_at: now,
43            updated_at: now,
44        }
45    }
46
47    /// Increment `resource_version` and stamp `updated_at`.
48    pub fn bump_resource_version(&mut self) {
49        self.updated_at = SystemTime::now();
50        self.resource_version += 1;
51    }
52}
53
54pub(crate) mod time_serde {
55    use serde::{Deserialize, Deserializer, Serialize, Serializer};
56    use std::time::{SystemTime, UNIX_EPOCH};
57
58    pub fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
59    where
60        S: Serializer,
61    {
62        let since_epoch = time.duration_since(UNIX_EPOCH).unwrap_or_default();
63        let ms = since_epoch.as_secs() * 1_000 + u64::from(since_epoch.subsec_millis());
64        ms.serialize(serializer)
65    }
66
67    pub fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
68    where
69        D: Deserializer<'de>,
70    {
71        let ms = u64::deserialize(deserializer)?;
72        UNIX_EPOCH
73            .checked_add(std::time::Duration::from_millis(ms))
74            .ok_or_else(|| <D::Error as serde::de::Error>::custom("timestamp out of range"))
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn new_sets_defaults() {
84        let meta = ObjectMeta::new("test-id".into());
85        assert_eq!(meta.id, "test-id");
86        assert_eq!(meta.resource_version, 1);
87    }
88
89    #[test]
90    fn bump_resource_version_increments() {
91        let mut meta = ObjectMeta::new("t".into());
92        meta.bump_resource_version();
93        assert_eq!(meta.resource_version, 2);
94    }
95
96    #[test]
97    fn serde_roundtrip() {
98        let meta = ObjectMeta::new("id-1".into());
99        let json = serde_json::to_string(&meta).unwrap();
100        let back: ObjectMeta = serde_json::from_str(&json).unwrap();
101        assert_eq!(back.id, meta.id);
102        assert_eq!(back.resource_version, 1);
103    }
104
105    #[test]
106    fn serialize_handles_pre_epoch_timestamp() {
107        use std::time::{Duration, UNIX_EPOCH};
108        let mut meta = ObjectMeta::new("pre-epoch".into());
109        meta.created_at = UNIX_EPOCH - Duration::from_secs(10);
110        meta.updated_at = UNIX_EPOCH - Duration::from_secs(5);
111
112        let json = serde_json::to_string(&meta).expect("must not fail");
113        assert!(json.contains(r#""createdAt":0"#));
114        assert!(json.contains(r#""updatedAt":0"#));
115    }
116}