Skip to main content

noether_store/
memory.rs

1use crate::invariant::{
2    duplicate_active_ids_for, duplicate_active_ids_for_incoming, log_auto_deprecation,
3};
4use crate::lifecycle::validate_transition;
5use crate::traits::{StageStore, StoreError, StoreStats};
6use noether_core::stage::{Stage, StageId, StageLifecycle};
7use std::collections::{BTreeMap, HashMap};
8
9/// In-memory stage store for testing and development.
10#[derive(Debug, Default)]
11pub struct MemoryStore {
12    stages: HashMap<String, Stage>,
13}
14
15impl MemoryStore {
16    pub fn new() -> Self {
17        Self::default()
18    }
19
20    pub fn len(&self) -> usize {
21        self.stages.len()
22    }
23
24    pub fn is_empty(&self) -> bool {
25        self.stages.is_empty()
26    }
27
28    /// Test-only escape hatch: insert a stage without the
29    /// invariant-enforcement path. Exists so tests can set up
30    /// deliberately-broken store states (e.g. two Active stages with
31    /// the same `signature_id`) that can't be created through the
32    /// public API. Do NOT use from production code.
33    #[doc(hidden)]
34    pub fn inject_raw_for_testing(&mut self, stage: Stage) {
35        self.stages.insert(stage.id.0.clone(), stage);
36    }
37}
38
39impl StageStore for MemoryStore {
40    fn put(&mut self, stage: Stage) -> Result<StageId, StoreError> {
41        let id = stage.id.clone();
42        if self.stages.contains_key(&id.0) {
43            return Err(StoreError::AlreadyExists(id));
44        }
45
46        // M2.3 invariant: at most one Active stage per signature_id.
47        // If the incoming stage is Active and shares a signature_id with
48        // other Active stages, auto-deprecate them with the new stage as
49        // the successor. This enforces the constraint the resolver's
50        // `get_by_signature` assumes — previously only the `stage add`
51        // CLI path did this check; direct library `put` could violate
52        // it silently.
53        let duplicates = duplicate_active_ids_for_incoming(&self.stages, &stage);
54        let signature_id = stage.signature_id.clone();
55
56        self.stages.insert(id.0.clone(), stage);
57
58        // Deprecate duplicates by direct mutation. Skipping
59        // validate_transition is intentional: Active → Deprecated is
60        // always valid, and the successor (just-inserted new stage)
61        // is already in the store.
62        for old_id in &duplicates {
63            if let Some(existing) = self.stages.get_mut(&old_id.0) {
64                existing.lifecycle = StageLifecycle::Deprecated {
65                    successor_id: id.clone(),
66                };
67            }
68        }
69        log_auto_deprecation(&duplicates, &id, signature_id.as_ref());
70
71        Ok(id)
72    }
73
74    fn upsert(&mut self, stage: Stage) -> Result<StageId, StoreError> {
75        let id = stage.id.clone();
76        let duplicates = duplicate_active_ids_for_incoming(&self.stages, &stage);
77        let signature_id = stage.signature_id.clone();
78        self.stages.insert(id.0.clone(), stage);
79        let actually_deprecated: Vec<StageId> = duplicates
80            .into_iter()
81            .filter(|old_id| *old_id != id)
82            .collect();
83        for old_id in &actually_deprecated {
84            if let Some(existing) = self.stages.get_mut(&old_id.0) {
85                existing.lifecycle = StageLifecycle::Deprecated {
86                    successor_id: id.clone(),
87                };
88            }
89        }
90        log_auto_deprecation(&actually_deprecated, &id, signature_id.as_ref());
91        Ok(id)
92    }
93
94    fn remove(&mut self, id: &StageId) -> Result<(), StoreError> {
95        self.stages.remove(&id.0);
96        Ok(())
97    }
98
99    fn get(&self, id: &StageId) -> Result<Option<&Stage>, StoreError> {
100        Ok(self.stages.get(&id.0))
101    }
102
103    fn contains(&self, id: &StageId) -> bool {
104        self.stages.contains_key(&id.0)
105    }
106
107    fn list(&self, lifecycle: Option<&StageLifecycle>) -> Vec<&Stage> {
108        self.stages
109            .values()
110            .filter(|s| lifecycle.is_none() || lifecycle == Some(&s.lifecycle))
111            .collect()
112    }
113
114    fn update_lifecycle(
115        &mut self,
116        id: &StageId,
117        lifecycle: StageLifecycle,
118    ) -> Result<(), StoreError> {
119        // Validate all preconditions before taking a mutable borrow
120        let current = self
121            .stages
122            .get(&id.0)
123            .ok_or_else(|| StoreError::NotFound(id.clone()))?;
124
125        validate_transition(&current.lifecycle, &lifecycle)
126            .map_err(|reason| StoreError::InvalidTransition { reason })?;
127
128        if let StageLifecycle::Deprecated { ref successor_id } = lifecycle {
129            if !self.stages.contains_key(&successor_id.0) {
130                return Err(StoreError::InvalidSuccessor {
131                    reason: format!("successor {successor_id:?} not found in store"),
132                });
133            }
134        }
135
136        // M2.3 invariant: when promoting to Active, check whether any
137        // other Active stage shares this one's signature_id and
138        // auto-deprecate it.
139        let (duplicates, signature_id) = if matches!(lifecycle, StageLifecycle::Active) {
140            let sig = current.signature_id.clone();
141            (
142                duplicate_active_ids_for(&self.stages, id, sig.as_ref()),
143                sig,
144            )
145        } else {
146            (Vec::new(), None)
147        };
148
149        // Now safe to mutate
150        self.stages.get_mut(&id.0).unwrap().lifecycle = lifecycle;
151        for old_id in &duplicates {
152            if let Some(existing) = self.stages.get_mut(&old_id.0) {
153                existing.lifecycle = StageLifecycle::Deprecated {
154                    successor_id: id.clone(),
155                };
156            }
157        }
158        log_auto_deprecation(&duplicates, id, signature_id.as_ref());
159        Ok(())
160    }
161
162    fn stats(&self) -> StoreStats {
163        let mut by_lifecycle: BTreeMap<String, usize> = BTreeMap::new();
164        let mut by_effect: BTreeMap<String, usize> = BTreeMap::new();
165
166        for stage in self.stages.values() {
167            let lc_name = match &stage.lifecycle {
168                StageLifecycle::Draft => "draft",
169                StageLifecycle::Active => "active",
170                StageLifecycle::Deprecated { .. } => "deprecated",
171                StageLifecycle::Tombstone => "tombstone",
172            };
173            *by_lifecycle.entry(lc_name.into()).or_default() += 1;
174
175            for effect in stage.signature.effects.iter() {
176                let effect_name = format!("{effect:?}");
177                *by_effect.entry(effect_name).or_default() += 1;
178            }
179        }
180
181        StoreStats {
182            total: self.stages.len(),
183            by_lifecycle,
184            by_effect,
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use noether_core::effects::EffectSet;
193    use noether_core::stage::{CostEstimate, StageSignature};
194    use noether_core::types::NType;
195    use std::collections::BTreeSet;
196
197    fn make_stage(id: &str) -> Stage {
198        Stage {
199            id: StageId(id.into()),
200            signature_id: None,
201            signature: StageSignature {
202                input: NType::Text,
203                output: NType::Number,
204                effects: EffectSet::pure(),
205                implementation_hash: format!("impl_{id}"),
206            },
207            capabilities: BTreeSet::new(),
208            cost: CostEstimate {
209                time_ms_p50: None,
210                tokens_est: None,
211                memory_mb: None,
212            },
213            description: "test stage".into(),
214            examples: vec![],
215            lifecycle: StageLifecycle::Active,
216            ed25519_signature: None,
217            signer_public_key: None,
218            implementation_code: None,
219            implementation_language: None,
220            ui_style: None,
221            tags: vec![],
222            aliases: vec![],
223            name: None,
224            properties: Vec::new(),
225        }
226    }
227
228    #[test]
229    fn put_and_get() {
230        let mut store = MemoryStore::new();
231        let stage = make_stage("abc123");
232        store.put(stage.clone()).unwrap();
233        let retrieved = store.get(&StageId("abc123".into())).unwrap().unwrap();
234        assert_eq!(retrieved.id, stage.id);
235    }
236
237    #[test]
238    fn duplicate_put_fails() {
239        let mut store = MemoryStore::new();
240        store.put(make_stage("abc123")).unwrap();
241        assert!(store.put(make_stage("abc123")).is_err());
242    }
243
244    #[test]
245    fn valid_lifecycle_transition() {
246        let mut store = MemoryStore::new();
247        let mut draft = make_stage("abc123");
248        draft.lifecycle = StageLifecycle::Draft;
249        store.put(draft).unwrap();
250        store
251            .update_lifecycle(&StageId("abc123".into()), StageLifecycle::Active)
252            .unwrap();
253        let stage = store.get(&StageId("abc123".into())).unwrap().unwrap();
254        assert_eq!(stage.lifecycle, StageLifecycle::Active);
255    }
256
257    #[test]
258    fn invalid_lifecycle_transition_fails() {
259        let mut store = MemoryStore::new();
260        let mut draft = make_stage("abc123");
261        draft.lifecycle = StageLifecycle::Draft;
262        store.put(draft).unwrap();
263        // Draft → Tombstone is invalid
264        let result = store.update_lifecycle(&StageId("abc123".into()), StageLifecycle::Tombstone);
265        assert!(result.is_err());
266    }
267
268    #[test]
269    fn deprecation_requires_valid_successor() {
270        let mut store = MemoryStore::new();
271        store.put(make_stage("old")).unwrap();
272        // Try to deprecate pointing to a nonexistent successor
273        let result = store.update_lifecycle(
274            &StageId("old".into()),
275            StageLifecycle::Deprecated {
276                successor_id: StageId("nonexistent".into()),
277            },
278        );
279        assert!(result.is_err());
280
281        // Now add the successor and try again
282        store.put(make_stage("new")).unwrap();
283        store
284            .update_lifecycle(
285                &StageId("old".into()),
286                StageLifecycle::Deprecated {
287                    successor_id: StageId("new".into()),
288                },
289            )
290            .unwrap();
291    }
292
293    #[test]
294    fn get_by_signature_returns_active_impl() {
295        use noether_core::stage::SignatureId;
296        let mut store = MemoryStore::new();
297        let mut stage = make_stage("impl_a");
298        stage.signature_id = Some(SignatureId("sig_one".into()));
299        store.put(stage).unwrap();
300
301        let found = store.get_by_signature(&SignatureId("sig_one".into()));
302        assert!(found.is_some(), "stage pinned by signature should resolve");
303        assert_eq!(found.unwrap().id, StageId("impl_a".into()));
304
305        assert!(store
306            .get_by_signature(&SignatureId("sig_missing".into()))
307            .is_none());
308    }
309
310    #[test]
311    fn get_by_signature_skips_deprecated() {
312        use noether_core::stage::SignatureId;
313        let mut store = MemoryStore::new();
314        // Old implementation of "sig" goes Active, new Active stage
315        // becomes successor. The M2.3 invariant enforcement in
316        // MemoryStore::put auto-deprecates the old one.
317        let mut old = make_stage("impl_old");
318        old.signature_id = Some(SignatureId("sig".into()));
319        store.put(old).unwrap();
320        let mut new = make_stage("impl_new");
321        new.signature_id = Some(SignatureId("sig".into()));
322        store.put(new).unwrap();
323
324        // old should already be Deprecated — no manual update_lifecycle
325        // needed.
326        assert!(matches!(
327            store
328                .get(&StageId("impl_old".into()))
329                .unwrap()
330                .unwrap()
331                .lifecycle,
332            StageLifecycle::Deprecated { .. }
333        ));
334
335        let found = store.get_by_signature(&SignatureId("sig".into())).unwrap();
336        assert_eq!(found.id, StageId("impl_new".into()));
337    }
338
339    #[test]
340    fn put_enforces_one_active_per_signature() {
341        use noether_core::stage::SignatureId;
342        let mut store = MemoryStore::new();
343        let mut a = make_stage("impl_a");
344        a.signature_id = Some(SignatureId("sig".into()));
345        let mut b = make_stage("impl_b");
346        b.signature_id = Some(SignatureId("sig".into()));
347
348        store.put(a).unwrap();
349        store.put(b).unwrap();
350
351        // impl_a must have been auto-deprecated with impl_b as successor.
352        let stored_a = store.get(&StageId("impl_a".into())).unwrap().unwrap();
353        match &stored_a.lifecycle {
354            StageLifecycle::Deprecated { successor_id } => {
355                assert_eq!(successor_id.0, "impl_b");
356            }
357            other => panic!("expected Deprecated, got {other:?}"),
358        }
359
360        // impl_b stays Active.
361        let stored_b = store.get(&StageId("impl_b".into())).unwrap().unwrap();
362        assert!(matches!(stored_b.lifecycle, StageLifecycle::Active));
363    }
364
365    #[test]
366    fn put_draft_does_not_trigger_deprecation() {
367        // Only an Active incoming stage should auto-deprecate. A Draft
368        // put must leave existing Actives alone.
369        use noether_core::stage::SignatureId;
370        let mut store = MemoryStore::new();
371        let mut active = make_stage("impl_active");
372        active.signature_id = Some(SignatureId("sig".into()));
373        let mut draft = make_stage("impl_draft");
374        draft.signature_id = Some(SignatureId("sig".into()));
375        draft.lifecycle = StageLifecycle::Draft;
376
377        store.put(active).unwrap();
378        store.put(draft).unwrap();
379
380        let stored = store.get(&StageId("impl_active".into())).unwrap().unwrap();
381        assert!(
382            matches!(stored.lifecycle, StageLifecycle::Active),
383            "draft put must not deprecate existing Active"
384        );
385    }
386
387    #[test]
388    fn update_lifecycle_to_active_deprecates_existing() {
389        // Promoting a Draft to Active must also trigger the invariant
390        // check, not just put.
391        use noether_core::stage::SignatureId;
392        let mut store = MemoryStore::new();
393        let mut existing = make_stage("impl_existing");
394        existing.signature_id = Some(SignatureId("sig".into()));
395        let mut draft = make_stage("impl_new");
396        draft.signature_id = Some(SignatureId("sig".into()));
397        draft.lifecycle = StageLifecycle::Draft;
398
399        store.put(existing).unwrap();
400        store.put(draft).unwrap();
401
402        // Promote draft → Active.
403        store
404            .update_lifecycle(&StageId("impl_new".into()), StageLifecycle::Active)
405            .unwrap();
406
407        let stored_existing = store
408            .get(&StageId("impl_existing".into()))
409            .unwrap()
410            .unwrap();
411        assert!(
412            matches!(stored_existing.lifecycle, StageLifecycle::Deprecated { .. }),
413            "existing Active must be auto-deprecated when another stage \
414             with the same signature is promoted to Active"
415        );
416    }
417
418    #[test]
419    fn upsert_enforces_one_active_per_signature() {
420        // Same invariant as `put`, but via the `upsert` codepath. An
421        // upsert writing a new Active with the same signature as an
422        // existing Active must auto-deprecate the existing one.
423        use noether_core::stage::SignatureId;
424        let mut store = MemoryStore::new();
425        let mut a = make_stage("impl_a");
426        a.signature_id = Some(SignatureId("sig".into()));
427        let mut b = make_stage("impl_b");
428        b.signature_id = Some(SignatureId("sig".into()));
429
430        store.upsert(a).unwrap();
431        store.upsert(b).unwrap();
432
433        let stored_a = store.get(&StageId("impl_a".into())).unwrap().unwrap();
434        match &stored_a.lifecycle {
435            StageLifecycle::Deprecated { successor_id } => {
436                assert_eq!(successor_id.0, "impl_b");
437            }
438            other => panic!("expected Deprecated, got {other:?}"),
439        }
440
441        let stored_b = store.get(&StageId("impl_b".into())).unwrap().unwrap();
442        assert!(matches!(stored_b.lifecycle, StageLifecycle::Active));
443    }
444
445    #[test]
446    fn upsert_replacing_self_does_not_deprecate_self() {
447        // Upserting a stage onto itself (same id) must leave it Active.
448        // Regression check: an earlier draft of the invariant helper
449        // would have flagged the stage as a duplicate of itself.
450        use noether_core::stage::SignatureId;
451        let mut store = MemoryStore::new();
452        let mut stage = make_stage("impl");
453        stage.signature_id = Some(SignatureId("sig".into()));
454        store.upsert(stage.clone()).unwrap();
455        store.upsert(stage).unwrap();
456
457        let stored = store.get(&StageId("impl".into())).unwrap().unwrap();
458        assert!(
459            matches!(stored.lifecycle, StageLifecycle::Active),
460            "self-upsert must not auto-deprecate"
461        );
462    }
463
464    #[test]
465    fn list_filters_by_lifecycle() {
466        let mut store = MemoryStore::new();
467        store.put(make_stage("a")).unwrap();
468        let mut draft = make_stage("b");
469        draft.lifecycle = StageLifecycle::Draft;
470        store.put(draft).unwrap();
471
472        let active = store.list(Some(&StageLifecycle::Active));
473        assert_eq!(active.len(), 1);
474        let all = store.list(None);
475        assert_eq!(all.len(), 2);
476    }
477
478    #[test]
479    fn stats_returns_counts() {
480        let mut store = MemoryStore::new();
481        store.put(make_stage("a")).unwrap();
482        store.put(make_stage("b")).unwrap();
483        let mut draft = make_stage("c");
484        draft.lifecycle = StageLifecycle::Draft;
485        store.put(draft).unwrap();
486
487        let stats = store.stats();
488        assert_eq!(stats.total, 3);
489        assert_eq!(stats.by_lifecycle.get("active"), Some(&2));
490        assert_eq!(stats.by_lifecycle.get("draft"), Some(&1));
491    }
492}