noether_store/traits.rs
1use noether_core::stage::{SignatureId, Stage, StageId, StageLifecycle};
2use std::collections::BTreeMap;
3
4#[derive(Debug, thiserror::Error)]
5pub enum StoreError {
6 #[error("stage with id {0:?} already exists")]
7 AlreadyExists(StageId),
8 #[error("stage with id {0:?} not found")]
9 NotFound(StageId),
10 #[error("invalid lifecycle transition: {reason}")]
11 InvalidTransition { reason: String },
12 #[error("invalid successor: {reason}")]
13 InvalidSuccessor { reason: String },
14 #[error("validation failed: {0:?}")]
15 ValidationFailed(Vec<String>),
16 #[error("I/O error: {message}")]
17 IoError { message: String },
18}
19
20/// Summary statistics for a store.
21#[derive(Debug, Clone)]
22pub struct StoreStats {
23 pub total: usize,
24 pub by_lifecycle: BTreeMap<String, usize>,
25 pub by_effect: BTreeMap<String, usize>,
26}
27
28/// Abstraction over stage storage.
29pub trait StageStore {
30 fn put(&mut self, stage: Stage) -> Result<StageId, StoreError>;
31 /// Insert a stage, replacing any existing stage with the same ID.
32 /// Used to upgrade unsigned stdlib stages after signing is added.
33 fn upsert(&mut self, stage: Stage) -> Result<StageId, StoreError>;
34 /// Remove a stage entirely. Returns `Ok(())` whether or not the stage existed.
35 fn remove(&mut self, id: &StageId) -> Result<(), StoreError>;
36 fn get(&self, id: &StageId) -> Result<Option<&Stage>, StoreError>;
37 fn contains(&self, id: &StageId) -> bool;
38 fn list(&self, lifecycle: Option<&StageLifecycle>) -> Vec<&Stage>;
39 fn update_lifecycle(
40 &mut self,
41 id: &StageId,
42 lifecycle: StageLifecycle,
43 ) -> Result<(), StoreError>;
44 fn stats(&self) -> StoreStats;
45
46 // ── Owned accessors (default impls — no need to override) ──────────────
47
48 /// Return an owned clone of the stage. Useful for async contexts where
49 /// holding a borrow across lock boundaries is not permitted.
50 fn get_owned(&self, id: &StageId) -> Result<Option<Stage>, StoreError> {
51 Ok(self.get(id)?.cloned())
52 }
53
54 /// Return owned clones of all matching stages.
55 fn list_owned(&self, lifecycle: Option<&StageLifecycle>) -> Vec<Stage> {
56 self.list(lifecycle).into_iter().cloned().collect()
57 }
58
59 /// Find all stages whose metadata `name` field matches exactly.
60 /// Used by graph loaders so composition files can reference stages
61 /// by their human-authored name instead of their 8-char content-hash
62 /// prefix. Returns every match across all lifecycles; callers
63 /// typically filter for `Active`.
64 fn find_by_name(&self, name: &str) -> Vec<&Stage> {
65 self.list(None)
66 .into_iter()
67 .filter(|s| s.name.as_deref() == Some(name))
68 .collect()
69 }
70
71 /// Look up the Active stage for a given [`SignatureId`]. This is
72 /// the M2 "resolve signature to latest implementation" pathway: a
73 /// graph that pins a stage by `signature_id` gets whichever
74 /// implementation is Active today.
75 ///
76 /// **Determinism.** When multiple Active stages share a signature
77 /// (which a well-behaved store prevents via the `stage add`
78 /// deprecation path, but which can happen transiently), this
79 /// returns the stage with the lexicographically-smallest
80 /// implementation ID. A "first match" would be nondeterministic
81 /// under HashMap-backed stores.
82 ///
83 /// Callers that need to distinguish the "zero matches" and "many
84 /// matches" cases should use [`active_stages_with_signature`] and
85 /// inspect the length.
86 fn get_by_signature(&self, signature_id: &SignatureId) -> Option<&Stage> {
87 self.list(Some(&StageLifecycle::Active))
88 .into_iter()
89 .filter(|s| s.signature_id.as_ref() == Some(signature_id))
90 .min_by(|a, b| a.id.0.cmp(&b.id.0))
91 }
92
93 /// Return every Active stage whose `signature_id` matches.
94 /// Ordered lexicographically by implementation ID so iteration is
95 /// stable across HashMap-backed stores.
96 ///
97 /// This is the diagnostic surface: a well-behaved store should
98 /// return at most one entry here. A call that returns more is a
99 /// signal that the "≤1 Active per signature" invariant has been
100 /// broken — typically by a direct `store.put` + lifecycle change
101 /// that bypassed the `stage add` deprecation path. The resolver
102 /// uses this helper to warn on multi-match rather than silently
103 /// picking one.
104 fn active_stages_with_signature(&self, signature_id: &SignatureId) -> Vec<&Stage> {
105 let mut matches: Vec<&Stage> = self
106 .list(Some(&StageLifecycle::Active))
107 .into_iter()
108 .filter(|s| s.signature_id.as_ref() == Some(signature_id))
109 .collect();
110 matches.sort_by(|a, b| a.id.0.cmp(&b.id.0));
111 matches
112 }
113}