Skip to main content

memstead_base/
store.rs

1//! In-memory graph store. Dumb data structure — no validation, no side effects.
2//! All mutations go through Engine methods.
3
4use crate::entity::{Entity, EntityId};
5use std::collections::HashMap;
6
7/// Edge in the graph.
8#[derive(Debug, Clone, PartialEq)]
9pub struct Edge {
10    pub rel_type: String,
11    pub target: EntityId,
12    pub source: EdgeSource,
13}
14
15/// Where an edge was declared. Under the alias model every authored
16/// edge is `Explicit` (an entry in the auto-managed `## Relationships`
17/// section); `Hierarchy` is a derived view over `PART_OF` rather than
18/// an authoring channel; `BodyLink` is engine-emitted from a body
19/// wiki-link via the alias-synthesis pass (rel-type equals the source
20/// schema's `alias_target_rel_type` pointer).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum EdgeSource {
23    /// Declared in the Relationships section.
24    Explicit,
25    /// Derived from PART_OF hierarchy.
26    Hierarchy,
27    /// Engine-emitted from a body wiki-link via the alias-synthesis
28    /// pass. The discriminator is store-side only — derived at
29    /// store-build time from `rel_type == schema.alias_target_rel_type()`.
30    BodyLink,
31}
32
33/// Incoming edge — stored in in_edges for efficient reverse lookups.
34#[derive(Debug, Clone, PartialEq)]
35pub struct InEdge {
36    pub rel_type: String,
37    pub from: EntityId,
38    pub source: EdgeSource,
39}
40
41/// The graph store. Three maps: nodes, outgoing edges, incoming edges.
42///
43/// `Clone` backs the atomic-batch rollback: `batch_update` snapshots
44/// the store before preparing items so a refused batch can restore the
45/// pre-call graph wholesale.
46#[derive(Debug, Clone)]
47pub struct Store {
48    nodes: HashMap<EntityId, Entity>,
49    out_edges: HashMap<EntityId, Vec<Edge>>,
50    in_edges: HashMap<EntityId, Vec<InEdge>>,
51}
52
53impl Store {
54    pub fn new() -> Self {
55        Self {
56            nodes: HashMap::new(),
57            out_edges: HashMap::new(),
58            in_edges: HashMap::new(),
59        }
60    }
61
62    /// Insert or update a node. If the node already exists, replace it.
63    pub fn upsert(&mut self, id: EntityId, entity: Entity) {
64        if !self.out_edges.contains_key(&id) {
65            self.out_edges.insert(id.clone(), Vec::new());
66        }
67        if !self.in_edges.contains_key(&id) {
68            self.in_edges.insert(id.clone(), Vec::new());
69        }
70        self.nodes.insert(id, entity);
71    }
72
73    /// Remove a node and cascade-delete all its edges.
74    pub fn remove(&mut self, id: &EntityId) -> Option<Entity> {
75        // Remove outgoing edges and their mirrors in in_edges
76        if let Some(out) = self.out_edges.remove(id) {
77            for edge in &out {
78                if let Some(in_list) = self.in_edges.get_mut(&edge.target) {
79                    in_list.retain(|e| &e.from != id);
80                }
81            }
82        }
83        // Remove incoming edges and their mirrors in out_edges
84        if let Some(inc) = self.in_edges.remove(id) {
85            for edge in &inc {
86                if let Some(out_list) = self.out_edges.get_mut(&edge.from) {
87                    out_list.retain(|e| &e.target != id);
88                }
89            }
90        }
91        self.nodes.remove(id)
92    }
93
94    pub fn get(&self, id: &EntityId) -> Option<&Entity> {
95        self.nodes.get(id)
96    }
97
98    pub fn get_mut(&mut self, id: &EntityId) -> Option<&mut Entity> {
99        self.nodes.get_mut(id)
100    }
101
102    pub fn contains(&self, id: &EntityId) -> bool {
103        self.nodes.contains_key(id)
104    }
105
106    pub fn all_ids(&self) -> impl Iterator<Item = &EntityId> {
107        self.nodes.keys()
108    }
109
110    pub fn all_entities(&self) -> impl Iterator<Item = &Entity> {
111        self.nodes.values()
112    }
113
114    /// Drop every entity whose `EntityId::mem()` matches `mem`,
115    /// cascading edges via the existing [`Store::remove`] mechanism.
116    /// Returns the number of entities removed (excluding edge-only
117    /// cascades — same accounting as `remove`).
118    ///
119    /// Used by [`Engine::reload_one_mem`] to clear one mem's slice
120    /// of the store before reloading entities from the on-disk branch
121    /// tip. Pure-iteration implementation: walks `all_ids()`, filters
122    /// by mem, then calls `remove` on each. The 132-entity workspace
123    /// today reloads the whole store in <1 s so the per-mem filtered
124    /// case is microseconds; if the workspace ever grows past
125    /// 10k entities the loop can switch to a mem-keyed bucket on
126    /// `Store` without changing this signature.
127    pub fn remove_entities_by_mem(&mut self, mem: &str) -> usize {
128        let to_remove: Vec<EntityId> = self
129            .nodes
130            .keys()
131            .filter(|id| id.mem() == mem)
132            .cloned()
133            .collect();
134        let count = to_remove.len();
135        for id in to_remove {
136            self.remove(&id);
137        }
138        count
139    }
140
141    pub fn len(&self) -> usize {
142        self.nodes.len()
143    }
144
145    pub fn is_empty(&self) -> bool {
146        self.nodes.is_empty()
147    }
148
149    /// Add an edge. Idempotent: if (from, to, type) exists, update source; else append.
150    /// Stores in both out_edges and in_edges for bidirectional traversal.
151    pub fn add_edge(&mut self, from: EntityId, edge: Edge) {
152        let target = edge.target.clone();
153        let rel_type = edge.rel_type.clone();
154        let source = edge.source.clone();
155
156        // Ensure adjacency lists exist
157        self.out_edges.entry(from.clone()).or_default();
158        self.in_edges.entry(target.clone()).or_default();
159
160        // Check for existing edge (same from, to, type)
161        let out_list = self.out_edges.get_mut(&from).unwrap();
162        if let Some(existing) = out_list
163            .iter_mut()
164            .find(|e| e.target == target && e.rel_type == rel_type)
165        {
166            existing.source = source.clone();
167            // Update mirror
168            if let Some(in_list) = self.in_edges.get_mut(&target)
169                && let Some(mirror) = in_list
170                    .iter_mut()
171                    .find(|e| e.from == from && e.rel_type == rel_type)
172            {
173                mirror.source = source;
174            }
175        } else {
176            out_list.push(edge);
177            self.in_edges.get_mut(&target).unwrap().push(InEdge {
178                rel_type,
179                from,
180                source,
181            });
182        }
183    }
184
185    /// Remove a specific edge by (from, to, type).
186    pub fn remove_edge(&mut self, from: &EntityId, to: &EntityId, rel_type: &str) {
187        if let Some(out_list) = self.out_edges.get_mut(from) {
188            out_list.retain(|e| !(e.target == *to && e.rel_type == rel_type));
189        }
190        if let Some(in_list) = self.in_edges.get_mut(to) {
191            in_list.retain(|e| !(e.from == *from && e.rel_type == rel_type));
192        }
193    }
194
195    /// Remove all outgoing edges from a node (and their mirrors).
196    pub fn remove_edges_from(&mut self, id: &EntityId) {
197        if let Some(out) = self.out_edges.get_mut(id) {
198            let edges = std::mem::take(out);
199            for edge in edges {
200                if let Some(in_list) = self.in_edges.get_mut(&edge.target) {
201                    in_list.retain(|e| &e.from != id);
202                }
203            }
204        }
205    }
206
207    /// Get all outgoing edges for a node.
208    pub fn outgoing(&self, id: &EntityId) -> &[Edge] {
209        self.out_edges.get(id).map_or(&[], |v| v.as_slice())
210    }
211
212    /// Get all incoming edges for a node.
213    pub fn incoming(&self, id: &EntityId) -> &[InEdge] {
214        self.in_edges.get(id).map_or(&[], |v| v.as_slice())
215    }
216
217    /// Rename a node. Updates all edge references.
218    pub fn rename_node(&mut self, old_id: &EntityId, new_id: EntityId) -> bool {
219        if old_id == &new_id {
220            return false;
221        }
222        let Some(mut entity) = self.nodes.remove(old_id) else {
223            return false;
224        };
225        entity.id = new_id.clone();
226        self.nodes.insert(new_id.clone(), entity);
227
228        // Move edge lists
229        let out = self.out_edges.remove(old_id).unwrap_or_default();
230        let inc = self.in_edges.remove(old_id).unwrap_or_default();
231        self.out_edges.insert(new_id.clone(), out);
232        self.in_edges.insert(new_id.clone(), inc);
233
234        // Update all edges referencing old_id
235        for edges in self.out_edges.values_mut() {
236            for e in edges.iter_mut() {
237                if e.target == *old_id {
238                    e.target = new_id.clone();
239                }
240            }
241        }
242        for edges in self.in_edges.values_mut() {
243            for e in edges.iter_mut() {
244                if e.from == *old_id {
245                    e.from = new_id.clone();
246                }
247            }
248        }
249
250        // Update `entity.relationships` on every node. This is the list that
251        // `write_entity` renders into the markdown frontmatter; without this
252        // walk a self-loop (`target == old_id`) would be written to disk
253        // under the old id, then re-parsed back as a fresh edge pointing at
254        // an auto-stubbed copy of the old id. Out/in edges alone aren't
255        // enough — the on-disk form is the source of truth that survives
256        // the post-rename re-parse cycle in `engine::mutation::rename`.
257        for entity in self.nodes.values_mut() {
258            for rel in entity.relationships.iter_mut() {
259                if rel.target == *old_id {
260                    rel.target = new_id.clone();
261                }
262            }
263        }
264        true
265    }
266
267    /// Total edge count (outgoing edges only, since in_edges are mirrors).
268    pub fn edge_count(&self) -> usize {
269        self.out_edges.values().map(|v| v.len()).sum()
270    }
271
272    /// Clear all nodes and edges.
273    pub fn clear(&mut self) {
274        self.nodes.clear();
275        self.out_edges.clear();
276        self.in_edges.clear();
277    }
278}
279
280impl Default for Store {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::Relationship;
290    use indexmap::IndexMap;
291
292    fn stub_entity(id: &str, mem: &str) -> Entity {
293        Entity {
294            id: EntityId(id.to_string()),
295            title: id.to_string(),
296            entity_type: "spec".to_string(),
297            mem: mem.to_string(),
298            file_path: String::new(),
299            metadata: IndexMap::new(),
300            sections: IndexMap::new(),
301            relationships: Vec::new(),
302            content_hash: String::new(),
303            stub: true,
304            stub_kind: None,
305            heading_spans: std::collections::HashMap::new(),
306        }
307    }
308
309    #[test]
310    fn new_store_is_empty() {
311        let store = Store::new();
312        assert!(store.is_empty());
313        assert_eq!(store.len(), 0);
314        assert_eq!(store.edge_count(), 0);
315    }
316
317    #[test]
318    fn upsert_and_get() {
319        let mut store = Store::new();
320        let id = EntityId("specs--test".to_string());
321        store.upsert(id.clone(), stub_entity("specs--test", "specs"));
322        assert_eq!(store.len(), 1);
323        assert!(store.get(&id).is_some());
324        assert_eq!(store.get(&id).unwrap().title, "specs--test");
325    }
326
327    #[test]
328    fn upsert_replaces_existing() {
329        let mut store = Store::new();
330        let id = EntityId("specs--test".to_string());
331        store.upsert(id.clone(), stub_entity("specs--test", "specs"));
332        let mut updated = stub_entity("specs--test", "specs");
333        updated.title = "Updated Title".to_string();
334        store.upsert(id.clone(), updated);
335        assert_eq!(store.len(), 1);
336        assert_eq!(store.get(&id).unwrap().title, "Updated Title");
337    }
338
339    #[test]
340    fn remove_node_cascades_edges() {
341        let mut store = Store::new();
342        let a = EntityId("specs--a".to_string());
343        let b = EntityId("specs--b".to_string());
344        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
345        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
346        store.add_edge(
347            a.clone(),
348            Edge {
349                rel_type: "USES".to_string(),
350                target: b.clone(),
351                source: EdgeSource::Explicit,
352            },
353        );
354        assert_eq!(store.edge_count(), 1);
355        store.remove(&b);
356        assert_eq!(store.len(), 1);
357        assert_eq!(store.edge_count(), 0);
358        assert!(store.outgoing(&a).is_empty());
359    }
360
361    #[test]
362    fn add_edge_idempotent() {
363        let mut store = Store::new();
364        let a = EntityId("specs--a".to_string());
365        let b = EntityId("specs--b".to_string());
366        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
367        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
368
369        store.add_edge(
370            a.clone(),
371            Edge {
372                rel_type: "USES".to_string(),
373                target: b.clone(),
374                source: EdgeSource::Explicit,
375            },
376        );
377        // Add same edge again — idempotent on (from, to, rel_type)
378        store.add_edge(
379            a.clone(),
380            Edge {
381                rel_type: "USES".to_string(),
382                target: b.clone(),
383                source: EdgeSource::Hierarchy,
384            },
385        );
386        assert_eq!(store.edge_count(), 1);
387        assert_eq!(store.outgoing(&a)[0].source, EdgeSource::Hierarchy);
388        assert_eq!(store.incoming(&b)[0].source, EdgeSource::Hierarchy);
389    }
390
391    #[test]
392    fn bidirectional_edges() {
393        let mut store = Store::new();
394        let a = EntityId("specs--a".to_string());
395        let b = EntityId("specs--b".to_string());
396        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
397        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
398        store.add_edge(
399            a.clone(),
400            Edge {
401                rel_type: "USES".to_string(),
402                target: b.clone(),
403                source: EdgeSource::Explicit,
404            },
405        );
406        assert_eq!(store.outgoing(&a).len(), 1);
407        assert_eq!(store.outgoing(&a)[0].target, b);
408        assert_eq!(store.incoming(&b).len(), 1);
409        assert_eq!(store.incoming(&b)[0].from, a);
410    }
411
412    #[test]
413    fn remove_edges_from() {
414        let mut store = Store::new();
415        let a = EntityId("specs--a".to_string());
416        let b = EntityId("specs--b".to_string());
417        let c = EntityId("specs--c".to_string());
418        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
419        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
420        store.upsert(c.clone(), stub_entity("specs--c", "specs"));
421        store.add_edge(
422            a.clone(),
423            Edge {
424                rel_type: "USES".to_string(),
425                target: b.clone(),
426                source: EdgeSource::Explicit,
427            },
428        );
429        store.add_edge(
430            a.clone(),
431            Edge {
432                rel_type: "USES".to_string(),
433                target: c.clone(),
434                source: EdgeSource::Explicit,
435            },
436        );
437        assert_eq!(store.edge_count(), 2);
438        store.remove_edges_from(&a);
439        assert_eq!(store.edge_count(), 0);
440        assert!(store.outgoing(&a).is_empty());
441        assert!(store.incoming(&b).is_empty());
442        assert!(store.incoming(&c).is_empty());
443    }
444
445    #[test]
446    fn remove_specific_edge() {
447        let mut store = Store::new();
448        let a = EntityId("specs--a".to_string());
449        let b = EntityId("specs--b".to_string());
450        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
451        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
452        store.add_edge(
453            a.clone(),
454            Edge {
455                rel_type: "USES".to_string(),
456                target: b.clone(),
457                source: EdgeSource::Explicit,
458            },
459        );
460        store.add_edge(
461            a.clone(),
462            Edge {
463                rel_type: "PART_OF".to_string(),
464                target: b.clone(),
465                source: EdgeSource::Explicit,
466            },
467        );
468        assert_eq!(store.edge_count(), 2);
469        store.remove_edge(&a, &b, "USES");
470        assert_eq!(store.edge_count(), 1);
471        assert_eq!(store.outgoing(&a)[0].rel_type, "PART_OF");
472    }
473
474    #[test]
475    fn rename_node() {
476        let mut store = Store::new();
477        let a = EntityId("specs--a".to_string());
478        let b = EntityId("specs--b".to_string());
479        let new_a = EntityId("specs--a-renamed".to_string());
480        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
481        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
482        store.add_edge(
483            a.clone(),
484            Edge {
485                rel_type: "USES".to_string(),
486                target: b.clone(),
487                source: EdgeSource::Explicit,
488            },
489        );
490        store.add_edge(
491            b.clone(),
492            Edge {
493                rel_type: "PART_OF".to_string(),
494                target: a.clone(),
495                source: EdgeSource::Explicit,
496            },
497        );
498
499        assert!(store.rename_node(&a, new_a.clone()));
500        assert!(store.get(&a).is_none());
501        assert!(store.get(&new_a).is_some());
502        assert_eq!(store.outgoing(&new_a).len(), 1);
503        assert_eq!(store.incoming(&new_a).len(), 1);
504        assert_eq!(store.incoming(&new_a)[0].from, b);
505        // Edge from b to old_a should now point to new_a
506        assert_eq!(store.outgoing(&b)[0].target, new_a);
507    }
508
509    #[test]
510    fn rename_node_rewrites_self_loop_in_relationships_vec() {
511        // Regression: a self-loop edge (target == self) stored in both the
512        // Store's adjacency HashMaps *and* inside `entity.relationships`
513        // used to have only the adjacency side rewritten on rename. The
514        // `relationships` Vec kept the old id, which then leaked onto disk
515        // via `write_entity` and auto-stubbed on re-parse.
516        let mut store = Store::new();
517        let old_id = EntityId("specs--selfie".to_string());
518        let new_id = EntityId("specs--selfie-renamed".to_string());
519        let mut entity = stub_entity("specs--selfie", "specs");
520        entity.stub = false;
521        entity.relationships.push(Relationship {
522            rel_type: "REFERENCES".to_string(),
523            target: old_id.clone(),
524            description: None,
525        });
526        store.upsert(old_id.clone(), entity);
527        store.add_edge(
528            old_id.clone(),
529            Edge {
530                rel_type: "REFERENCES".to_string(),
531                target: old_id.clone(),
532                source: EdgeSource::Explicit,
533            },
534        );
535
536        assert!(store.rename_node(&old_id, new_id.clone()));
537
538        let renamed = store.get(&new_id).expect("renamed entity exists");
539        assert_eq!(renamed.relationships.len(), 1);
540        assert_eq!(
541            renamed.relationships[0].target, new_id,
542            "self-loop target inside entity.relationships must be rewritten \
543             to new_id — otherwise write_entity leaks old id to disk"
544        );
545        // And the adjacency side stayed consistent (single self-loop, not
546        // duplicated into a dangling-to-old-id edge).
547        assert_eq!(store.outgoing(&new_id).len(), 1);
548        assert_eq!(store.outgoing(&new_id)[0].target, new_id);
549        assert_eq!(store.incoming(&new_id).len(), 1);
550        assert_eq!(store.incoming(&new_id)[0].from, new_id);
551    }
552
553    #[test]
554    fn clear_empties_store() {
555        let mut store = Store::new();
556        let a = EntityId("specs--a".to_string());
557        store.upsert(a, stub_entity("specs--a", "specs"));
558        store.clear();
559        assert!(store.is_empty());
560        assert_eq!(store.edge_count(), 0);
561    }
562
563    #[test]
564    fn outgoing_empty_for_unknown_id() {
565        let store = Store::new();
566        assert!(store.outgoing(&EntityId("unknown".to_string())).is_empty());
567    }
568}