Skip to main content

awaken_server_contract/contract/
config_store.rs

1//! Async CRUD storage for namespaced JSON configuration documents.
2
3use async_trait::async_trait;
4use serde_json::Value;
5
6use super::storage::StorageError;
7use crate::contract::scope::{ScopeId, scoped_key};
8use std::sync::Arc;
9
10/// Async CRUD store for namespaced JSON configuration documents.
11#[async_trait]
12pub trait ConfigStore: Send + Sync {
13    /// Get a single entry by namespace and ID.
14    async fn get(&self, namespace: &str, id: &str) -> Result<Option<Value>, StorageError>;
15
16    /// List entries in a namespace ordered by ID.
17    async fn list(
18        &self,
19        namespace: &str,
20        offset: usize,
21        limit: usize,
22    ) -> Result<Vec<(String, Value)>, StorageError>;
23
24    /// Create or overwrite an entry.
25    async fn put(&self, namespace: &str, id: &str, value: &Value) -> Result<(), StorageError>;
26
27    /// Delete an entry. Missing entries are not an error.
28    async fn delete(&self, namespace: &str, id: &str) -> Result<(), StorageError>;
29
30    /// Create an entry only when it does not already exist.
31    ///
32    /// Production stores should override this with an atomic implementation.
33    /// The default implementation is best-effort and exists for lightweight
34    /// test stores.
35    async fn put_if_absent(
36        &self,
37        namespace: &str,
38        id: &str,
39        value: &Value,
40    ) -> Result<(), StorageError> {
41        if self.exists(namespace, id).await? {
42            return Err(StorageError::AlreadyExists(format!("{namespace}/{id}")));
43        }
44        self.put(namespace, id, value).await
45    }
46
47    /// Check whether an entry exists.
48    async fn exists(&self, namespace: &str, id: &str) -> Result<bool, StorageError> {
49        Ok(self.get(namespace, id).await?.is_some())
50    }
51
52    /// Atomic compare-and-set on the record's `meta.revision`.
53    ///
54    /// Reads the existing record at `(namespace, id)`, extracts the integer
55    /// at JSON path `meta.revision` (defaulting to 0 if absent), and:
56    ///
57    /// - if it equals `expected_revision`, replaces the record with `value`
58    ///   and returns `Ok(())`.
59    /// - if it differs, returns
60    ///   `Err(StorageError::VersionConflict { expected, actual })`.
61    /// - if no existing record AND `expected_revision == 0`, creates the
62    ///   record (treats absence as revision 0).
63    ///
64    /// The new revision is the caller's responsibility: write `value` with
65    /// `meta.revision = expected_revision + 1` before calling this method.
66    /// The store does not modify the value it stores; it only enforces the
67    /// CAS predicate.
68    ///
69    /// **Implementations MUST guarantee atomicity** with respect to
70    /// concurrent `put` / `put_if_revision` / `delete` against the same
71    /// `(namespace, id)`. The default implementation here is best-effort
72    /// (read-then-put, racy across replicas) and is provided so existing
73    /// `ConfigStore` impls compile without modification — production-grade
74    /// stores must override.
75    async fn put_if_revision(
76        &self,
77        namespace: &str,
78        id: &str,
79        value: &Value,
80        expected_revision: u64,
81    ) -> Result<(), StorageError> {
82        let actual = self
83            .get(namespace, id)
84            .await?
85            .as_ref()
86            .and_then(extract_meta_revision)
87            .unwrap_or(0);
88        if actual != expected_revision {
89            return Err(StorageError::VersionConflict {
90                expected: expected_revision,
91                actual,
92            });
93        }
94        self.put(namespace, id, value).await
95    }
96
97    /// Delete only when the current record's `meta.revision` matches
98    /// `expected_revision`.
99    ///
100    /// Production stores must make this atomic with concurrent writes to the
101    /// same `(namespace, id)`. The default implementation is best-effort for
102    /// simple test stores.
103    async fn delete_if_revision(
104        &self,
105        namespace: &str,
106        id: &str,
107        expected_revision: u64,
108    ) -> Result<(), StorageError> {
109        let actual = self
110            .get(namespace, id)
111            .await?
112            .as_ref()
113            .and_then(extract_meta_revision)
114            .unwrap_or(0);
115        if actual != expected_revision {
116            return Err(StorageError::VersionConflict {
117                expected: expected_revision,
118                actual,
119            });
120        }
121        self.delete(namespace, id).await
122    }
123}
124
125/// Extract the `meta.revision` integer from a stored config document.
126///
127/// Returns `None` if the path does not exist or is not an integer (e.g. legacy
128/// bare-spec documents that predate the envelope format).
129pub fn extract_meta_revision(value: &Value) -> Option<u64> {
130    value
131        .get("meta")
132        .and_then(|m| m.get("revision"))
133        .and_then(|r| r.as_u64())
134}
135
136/// Type of config mutation that was published by a store notification.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum ConfigChangeKind {
140    Put,
141    Delete,
142}
143
144/// A config change notification emitted by a store implementation.
145#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
146pub struct ConfigChangeEvent {
147    pub namespace: String,
148    pub id: String,
149    pub kind: ConfigChangeKind,
150}
151
152/// Blocking/streaming receiver for store-native config change notifications.
153#[async_trait]
154pub trait ConfigChangeSubscriber: Send {
155    async fn next(&mut self) -> Result<ConfigChangeEvent, StorageError>;
156}
157
158/// Optional native notification capability for a [`ConfigStore`].
159///
160/// Stores that can push change events (for example PostgreSQL LISTEN/NOTIFY)
161/// should implement this in addition to [`ConfigStore`]. Callers should still
162/// keep a polling fallback because notifications may be delayed or unavailable.
163#[async_trait]
164pub trait ConfigChangeNotifier: Send + Sync {
165    async fn subscribe(&self) -> Result<Box<dyn ConfigChangeSubscriber>, StorageError>;
166}
167
168#[derive(Clone)]
169pub struct ScopedConfigStore {
170    inner: Arc<dyn ConfigStore>,
171    scope_id: ScopeId,
172}
173
174impl ScopedConfigStore {
175    pub fn new(inner: Arc<dyn ConfigStore>, scope_id: ScopeId) -> Self {
176        Self { inner, scope_id }
177    }
178
179    pub fn scope_id(&self) -> &ScopeId {
180        &self.scope_id
181    }
182
183    pub fn inner(&self) -> &dyn ConfigStore {
184        self.inner.as_ref()
185    }
186
187    fn scoped_namespace(&self, namespace: &str) -> String {
188        scoped_key(&self.scope_id, namespace)
189    }
190}
191
192#[async_trait]
193impl ConfigStore for ScopedConfigStore {
194    async fn get(&self, namespace: &str, id: &str) -> Result<Option<Value>, StorageError> {
195        self.inner.get(&self.scoped_namespace(namespace), id).await
196    }
197
198    async fn list(
199        &self,
200        namespace: &str,
201        offset: usize,
202        limit: usize,
203    ) -> Result<Vec<(String, Value)>, StorageError> {
204        self.inner
205            .list(&self.scoped_namespace(namespace), offset, limit)
206            .await
207    }
208
209    async fn put(&self, namespace: &str, id: &str, value: &Value) -> Result<(), StorageError> {
210        self.inner
211            .put(&self.scoped_namespace(namespace), id, value)
212            .await
213    }
214
215    async fn delete(&self, namespace: &str, id: &str) -> Result<(), StorageError> {
216        self.inner
217            .delete(&self.scoped_namespace(namespace), id)
218            .await
219    }
220
221    async fn put_if_absent(
222        &self,
223        namespace: &str,
224        id: &str,
225        value: &Value,
226    ) -> Result<(), StorageError> {
227        self.inner
228            .put_if_absent(&self.scoped_namespace(namespace), id, value)
229            .await
230    }
231
232    async fn exists(&self, namespace: &str, id: &str) -> Result<bool, StorageError> {
233        self.inner
234            .exists(&self.scoped_namespace(namespace), id)
235            .await
236    }
237
238    async fn put_if_revision(
239        &self,
240        namespace: &str,
241        id: &str,
242        value: &Value,
243        expected_revision: u64,
244    ) -> Result<(), StorageError> {
245        self.inner
246            .put_if_revision(
247                &self.scoped_namespace(namespace),
248                id,
249                value,
250                expected_revision,
251            )
252            .await
253    }
254
255    async fn delete_if_revision(
256        &self,
257        namespace: &str,
258        id: &str,
259        expected_revision: u64,
260    ) -> Result<(), StorageError> {
261        self.inner
262            .delete_if_revision(&self.scoped_namespace(namespace), id, expected_revision)
263            .await
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use std::collections::HashMap;
270    use std::sync::Arc;
271
272    use tokio::sync::RwLock;
273
274    use super::*;
275
276    // ── extract_meta_revision ────────────────────────────────────────
277
278    #[test]
279    fn extract_meta_revision_returns_some_for_valid_path() {
280        let value = serde_json::json!({"meta": {"revision": 42}});
281        assert_eq!(extract_meta_revision(&value), Some(42));
282    }
283
284    #[test]
285    fn extract_meta_revision_returns_none_for_missing_meta() {
286        let value = serde_json::json!({"spec": {"id": "foo"}});
287        assert_eq!(extract_meta_revision(&value), None);
288    }
289
290    #[test]
291    fn extract_meta_revision_returns_none_for_missing_revision_key() {
292        let value = serde_json::json!({"meta": {"source": {"kind": "user"}}});
293        assert_eq!(extract_meta_revision(&value), None);
294    }
295
296    #[test]
297    fn extract_meta_revision_returns_none_for_wrong_type() {
298        let value = serde_json::json!({"meta": {"revision": "not-a-number"}});
299        assert_eq!(extract_meta_revision(&value), None);
300    }
301
302    #[test]
303    fn extract_meta_revision_returns_none_for_bare_spec() {
304        // Legacy bare-spec shape (no envelope).
305        let value = serde_json::json!({"id": "agent-1", "name": "Alice"});
306        assert_eq!(extract_meta_revision(&value), None);
307    }
308
309    #[derive(Debug, Default)]
310    struct MemoryConfigStore {
311        data: RwLock<HashMap<String, HashMap<String, Value>>>,
312    }
313
314    #[async_trait]
315    impl ConfigStore for MemoryConfigStore {
316        async fn get(&self, namespace: &str, id: &str) -> Result<Option<Value>, StorageError> {
317            let data = self.data.read().await;
318            Ok(data.get(namespace).and_then(|ns| ns.get(id)).cloned())
319        }
320
321        async fn list(
322            &self,
323            namespace: &str,
324            offset: usize,
325            limit: usize,
326        ) -> Result<Vec<(String, Value)>, StorageError> {
327            let data = self.data.read().await;
328            let Some(namespace_data) = data.get(namespace) else {
329                return Ok(Vec::new());
330            };
331            let mut items: Vec<_> = namespace_data
332                .iter()
333                .map(|(id, value)| (id.clone(), value.clone()))
334                .collect();
335            items.sort_by(|left, right| left.0.cmp(&right.0));
336            Ok(items.into_iter().skip(offset).take(limit).collect())
337        }
338
339        async fn put(&self, namespace: &str, id: &str, value: &Value) -> Result<(), StorageError> {
340            let mut data = self.data.write().await;
341            data.entry(namespace.to_string())
342                .or_default()
343                .insert(id.to_string(), value.clone());
344            Ok(())
345        }
346
347        async fn delete(&self, namespace: &str, id: &str) -> Result<(), StorageError> {
348            let mut data = self.data.write().await;
349            if let Some(namespace_data) = data.get_mut(namespace) {
350                namespace_data.remove(id);
351            }
352            Ok(())
353        }
354    }
355
356    #[tokio::test]
357    async fn config_store_round_trip() {
358        let store: Arc<dyn ConfigStore> = Arc::new(MemoryConfigStore::default());
359        let value = serde_json::json!({"id": "alpha", "label": "first"});
360
361        store.put("tests", "alpha", &value).await.unwrap();
362
363        assert_eq!(store.get("tests", "alpha").await.unwrap(), Some(value));
364    }
365
366    #[tokio::test]
367    async fn config_store_lists_sorted_entries() {
368        let store: Arc<dyn ConfigStore> = Arc::new(MemoryConfigStore::default());
369
370        store
371            .put(
372                "tests",
373                "bravo",
374                &serde_json::json!({"id": "bravo", "label": "second"}),
375            )
376            .await
377            .unwrap();
378        store
379            .put(
380                "tests",
381                "alpha",
382                &serde_json::json!({"id": "alpha", "label": "first"}),
383            )
384            .await
385            .unwrap();
386
387        let items = store.list("tests", 0, 10).await.unwrap();
388        assert_eq!(items[0].0, "alpha");
389        assert_eq!(items[1].0, "bravo");
390    }
391}