Skip to main content

greentic_types/
state_capability.rs

1//! State capability contract types for `greentic.cap.state.kv.v1`.
2//!
3//! Defines the operation enum, payloads, and results used by state provider
4//! packs and the operator's native dispatch pipeline.
5
6use alloc::string::String;
7use alloc::vec::Vec;
8
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12/// Well-known capability identifier for key-value state providers.
13pub const CAP_STATE_KV_V1: &str = "greentic.cap.state.kv.v1";
14
15/// State operation kind.
16#[derive(Clone, Debug, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
18#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
19pub enum StateOp {
20    /// Read a single key.
21    Get,
22    /// Write a single key.
23    Put,
24    /// Delete a single key.
25    Delete,
26    /// List keys matching a prefix.
27    List,
28    /// Compare-and-swap: update only if the current version matches.
29    Cas,
30}
31
32impl StateOp {
33    /// Returns the canonical operation name string.
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::Get => "state.get",
37            Self::Put => "state.put",
38            Self::Delete => "state.delete",
39            Self::List => "state.list",
40            Self::Cas => "state.cas",
41        }
42    }
43}
44
45impl core::fmt::Display for StateOp {
46    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
47        f.write_str(self.as_str())
48    }
49}
50
51/// Payload for a state operation request.
52#[derive(Clone, Debug, PartialEq, Eq)]
53#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
54pub struct StateOpPayload {
55    /// Namespace scoping the key, typically `{env}::{tenant}::{team}`.
56    pub namespace: String,
57    /// Key within the namespace.
58    pub key: String,
59    /// Value bytes for `Put` / `Cas` operations.
60    #[cfg_attr(
61        feature = "serde",
62        serde(default, skip_serializing_if = "Option::is_none")
63    )]
64    pub value: Option<Vec<u8>>,
65    /// Optional time-to-live in seconds.
66    #[cfg_attr(
67        feature = "serde",
68        serde(default, skip_serializing_if = "Option::is_none")
69    )]
70    pub ttl_seconds: Option<u32>,
71    /// Expected version for compare-and-swap.
72    #[cfg_attr(
73        feature = "serde",
74        serde(default, skip_serializing_if = "Option::is_none")
75    )]
76    pub cas_version: Option<u64>,
77    /// Optional key prefix for `List` operations.
78    #[cfg_attr(
79        feature = "serde",
80        serde(default, skip_serializing_if = "Option::is_none")
81    )]
82    pub prefix: Option<String>,
83}
84
85impl StateOpPayload {
86    /// Creates a `Get` payload.
87    pub fn get(namespace: impl Into<String>, key: impl Into<String>) -> Self {
88        Self {
89            namespace: namespace.into(),
90            key: key.into(),
91            value: None,
92            ttl_seconds: None,
93            cas_version: None,
94            prefix: None,
95        }
96    }
97
98    /// Creates a `Put` payload.
99    pub fn put(namespace: impl Into<String>, key: impl Into<String>, value: Vec<u8>) -> Self {
100        Self {
101            namespace: namespace.into(),
102            key: key.into(),
103            value: Some(value),
104            ttl_seconds: None,
105            cas_version: None,
106            prefix: None,
107        }
108    }
109
110    /// Creates a `Delete` payload.
111    pub fn delete(namespace: impl Into<String>, key: impl Into<String>) -> Self {
112        Self {
113            namespace: namespace.into(),
114            key: key.into(),
115            value: None,
116            ttl_seconds: None,
117            cas_version: None,
118            prefix: None,
119        }
120    }
121
122    /// Creates a `List` payload with a prefix filter.
123    pub fn list(namespace: impl Into<String>, prefix: impl Into<String>) -> Self {
124        Self {
125            namespace: namespace.into(),
126            key: String::new(),
127            value: None,
128            ttl_seconds: None,
129            cas_version: None,
130            prefix: Some(prefix.into()),
131        }
132    }
133
134    /// Sets a TTL on this payload.
135    pub fn with_ttl(mut self, seconds: u32) -> Self {
136        self.ttl_seconds = Some(seconds);
137        self
138    }
139
140    /// Sets a CAS version on this payload.
141    pub fn with_cas_version(mut self, version: u64) -> Self {
142        self.cas_version = Some(version);
143        self
144    }
145}
146
147/// Result of a state operation.
148#[derive(Clone, Debug, PartialEq, Eq)]
149#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
150pub struct StateOpResult {
151    /// Whether the key was found (for `Get`) or operation succeeded.
152    pub found: bool,
153    /// Returned value bytes (for `Get`).
154    #[cfg_attr(
155        feature = "serde",
156        serde(default, skip_serializing_if = "Option::is_none")
157    )]
158    pub value: Option<Vec<u8>>,
159    /// Current version / etag if the backend supports it.
160    #[cfg_attr(
161        feature = "serde",
162        serde(default, skip_serializing_if = "Option::is_none")
163    )]
164    pub version: Option<u64>,
165    /// Matched keys for `List` operations.
166    #[cfg_attr(
167        feature = "serde",
168        serde(default, skip_serializing_if = "Option::is_none")
169    )]
170    pub keys: Option<Vec<String>>,
171    /// Error message if the operation failed.
172    #[cfg_attr(
173        feature = "serde",
174        serde(default, skip_serializing_if = "Option::is_none")
175    )]
176    pub error: Option<String>,
177}
178
179impl StateOpResult {
180    /// A successful result with a value.
181    pub fn found(value: Vec<u8>) -> Self {
182        Self {
183            found: true,
184            value: Some(value),
185            version: None,
186            keys: None,
187            error: None,
188        }
189    }
190
191    /// A not-found result.
192    pub fn not_found() -> Self {
193        Self {
194            found: false,
195            value: None,
196            version: None,
197            keys: None,
198            error: None,
199        }
200    }
201
202    /// A successful write/delete acknowledgement.
203    pub fn ok() -> Self {
204        Self {
205            found: true,
206            value: None,
207            version: None,
208            keys: None,
209            error: None,
210        }
211    }
212
213    /// A list result.
214    pub fn list(keys: Vec<String>) -> Self {
215        Self {
216            found: true,
217            value: None,
218            version: None,
219            keys: Some(keys),
220            error: None,
221        }
222    }
223
224    /// An error result.
225    pub fn err(message: impl Into<String>) -> Self {
226        Self {
227            found: false,
228            value: None,
229            version: None,
230            keys: None,
231            error: Some(message.into()),
232        }
233    }
234
235    /// Sets the version on this result.
236    pub fn with_version(mut self, version: u64) -> Self {
237        self.version = Some(version);
238        self
239    }
240}
241
242/// State backend kind resolved from capability offers.
243#[derive(Clone, Debug, PartialEq, Eq)]
244#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
245#[cfg_attr(feature = "serde", serde(tag = "backend", rename_all = "snake_case"))]
246pub enum StateBackendKind {
247    /// In-memory ephemeral store (dev/test).
248    Memory {
249        /// Maximum number of entries (0 = unlimited).
250        #[cfg_attr(feature = "serde", serde(default))]
251        max_entries: u32,
252        /// Default TTL in seconds (0 = no expiry).
253        #[cfg_attr(feature = "serde", serde(default))]
254        default_ttl_seconds: u32,
255    },
256    /// Redis-backed persistent store.
257    Redis {
258        /// Redis connection URL.
259        redis_url: String,
260        /// Key prefix for namespacing.
261        #[cfg_attr(feature = "serde", serde(default = "default_key_prefix"))]
262        key_prefix: String,
263        /// Default TTL in seconds (0 = no expiry).
264        #[cfg_attr(feature = "serde", serde(default))]
265        default_ttl_seconds: u32,
266        /// Connection pool size.
267        #[cfg_attr(feature = "serde", serde(default = "default_pool_size"))]
268        pool_size: u32,
269        /// Whether TLS is enabled.
270        #[cfg_attr(feature = "serde", serde(default))]
271        tls_enabled: bool,
272    },
273}
274
275#[cfg(feature = "serde")]
276fn default_key_prefix() -> String {
277    String::from("greentic")
278}
279
280#[cfg(feature = "serde")]
281const fn default_pool_size() -> u32 {
282    5
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn state_op_display() {
291        assert_eq!(StateOp::Get.as_str(), "state.get");
292        assert_eq!(StateOp::Put.as_str(), "state.put");
293        assert_eq!(StateOp::Delete.as_str(), "state.delete");
294        assert_eq!(StateOp::List.as_str(), "state.list");
295        assert_eq!(StateOp::Cas.as_str(), "state.cas");
296    }
297
298    #[test]
299    fn payload_builders() {
300        let p = StateOpPayload::get("dev::t1::team", "session:abc");
301        assert_eq!(p.namespace, "dev::t1::team");
302        assert_eq!(p.key, "session:abc");
303        assert!(p.value.is_none());
304
305        let p = StateOpPayload::put("dev::t1::team", "k", vec![1, 2, 3]).with_ttl(60);
306        assert_eq!(p.value, Some(vec![1, 2, 3]));
307        assert_eq!(p.ttl_seconds, Some(60));
308
309        let p = StateOpPayload::delete("ns", "k");
310        assert!(p.value.is_none());
311
312        let p = StateOpPayload::list("ns", "session:");
313        assert_eq!(p.prefix, Some("session:".to_string()));
314    }
315
316    #[test]
317    fn result_builders() {
318        let r = StateOpResult::found(vec![42]);
319        assert!(r.found);
320        assert_eq!(r.value, Some(vec![42]));
321
322        let r = StateOpResult::not_found();
323        assert!(!r.found);
324
325        let r = StateOpResult::ok().with_version(7);
326        assert!(r.found);
327        assert_eq!(r.version, Some(7));
328
329        let r = StateOpResult::list(vec!["a".into(), "b".into()]);
330        assert_eq!(r.keys, Some(vec!["a".to_string(), "b".to_string()]));
331
332        let r = StateOpResult::err("boom");
333        assert!(!r.found);
334        assert_eq!(r.error, Some("boom".to_string()));
335    }
336
337    #[test]
338    fn state_op_payload_json_roundtrip() {
339        let original = StateOpPayload::put("ns", "key", b"hello".to_vec()).with_ttl(300);
340        let json = match serde_json::to_string(&original) {
341            Ok(value) => value,
342            Err(err) => panic!("serialize: {err}"),
343        };
344        let decoded: StateOpPayload = match serde_json::from_str(&json) {
345            Ok(value) => value,
346            Err(err) => panic!("deserialize: {err}"),
347        };
348        assert_eq!(decoded.namespace, "ns");
349        assert_eq!(decoded.key, "key");
350        assert_eq!(decoded.value, Some(b"hello".to_vec()));
351        assert_eq!(decoded.ttl_seconds, Some(300));
352    }
353
354    #[test]
355    fn state_op_result_json_roundtrip() {
356        let original = StateOpResult::found(b"world".to_vec()).with_version(42);
357        let json = match serde_json::to_string(&original) {
358            Ok(value) => value,
359            Err(err) => panic!("serialize: {err}"),
360        };
361        let decoded: StateOpResult = match serde_json::from_str(&json) {
362            Ok(value) => value,
363            Err(err) => panic!("deserialize: {err}"),
364        };
365        assert!(decoded.found);
366        assert_eq!(decoded.value, Some(b"world".to_vec()));
367        assert_eq!(decoded.version, Some(42));
368    }
369
370    #[test]
371    fn state_backend_kind_json_roundtrip() {
372        let memory = StateBackendKind::Memory {
373            max_entries: 10000,
374            default_ttl_seconds: 0,
375        };
376        let json = match serde_json::to_string(&memory) {
377            Ok(value) => value,
378            Err(err) => panic!("serialize: {err}"),
379        };
380        assert!(json.contains("\"backend\":\"memory\""));
381        let decoded: StateBackendKind = match serde_json::from_str(&json) {
382            Ok(value) => value,
383            Err(err) => panic!("deserialize: {err}"),
384        };
385        assert_eq!(decoded, memory);
386
387        let redis = StateBackendKind::Redis {
388            redis_url: "redis://localhost:6379/0".into(),
389            key_prefix: "greentic".into(),
390            default_ttl_seconds: 3600,
391            pool_size: 10,
392            tls_enabled: false,
393        };
394        let json = match serde_json::to_string(&redis) {
395            Ok(value) => value,
396            Err(err) => panic!("serialize: {err}"),
397        };
398        assert!(json.contains("\"backend\":\"redis\""));
399        let decoded: StateBackendKind = match serde_json::from_str(&json) {
400            Ok(value) => value,
401            Err(err) => panic!("deserialize: {err}"),
402        };
403        assert_eq!(decoded, redis);
404    }
405}