Skip to main content

memstead_base/storage/
in_memory.rs

1//! In-memory [`MemBackend`](crate::backend::MemBackend) — the
2//! writable, filesystem- and git-free companion to the folder backend.
3//!
4//! The mem lives entirely in RAM: committed entity bytes, the staged
5//! mutation buffer, the provenance log, and the per-mem config all
6//! sit on one [`Mutex`]-guarded [`State`]. Creating a backend
7//! provisions nothing on disk; dropping it releases the mem with no
8//! residue to clean up. Built to serve ephemeral per-session
9//! playground mems the session server spins up and tears down on
10//! demand.
11//!
12//! ## Why model on the folder backend
13//!
14//! The folder backend ([`super::filesystem::FilesystemMemWriter`]) is
15//! the closest sibling: it buffers mutations until commit, mints a
16//! synthetic history-free commit-id, and keeps a sidecar provenance
17//! log. This backend mirrors that contract one-for-one — same
18//! buffer-then-commit semantics, same `read`-sees-pending ordering,
19//! same path-rejection rules (it reuses
20//! [`super::filesystem::normalise_rel_path`]), same synthetic commit-id
21//! ([`super::filesystem::make_commit_id`]) — so the engine produces
22//! identical engine-level outcomes regardless of which of the two
23//! serves a mount. The only difference is the substrate: a `HashMap`
24//! in RAM instead of a directory tree on disk.
25//!
26//! ## No durable history
27//!
28//! Like the folder backend, this one has no commit history:
29//! [`MemBackend::current_head`](crate::backend::MemBackend::current_head)
30//! inherits the trait default returning `Ok(None)` rather than
31//! fabricating a git-like head, and
32//! `commit_with_expected_parent` inherits the default that ignores the
33//! pin and delegates to [`commit`](crate::backend::MemBackend::commit).
34//! Optimistic locking is unaffected — the engine enforces the
35//! `expected_hash` CAS above the backend, so a stale hash trips the
36//! same `HASH_MISMATCH` here as on any other backend.
37
38use std::collections::HashMap;
39use std::path::{Path, PathBuf};
40use std::sync::Mutex;
41
42use super::CommitId;
43use super::filesystem::{make_commit_id, normalise_rel_path};
44use crate::backend::{BackendError, MemBackend};
45use crate::filesystem::changelog::format_rfc3339_utc;
46use crate::provenance::Provenance;
47use crate::vcs::CommitContext;
48
49/// Per-path terminal state for the staged op buffer. Move resolves at
50/// call time into a `Delete(from)` + `Upsert(to, bytes)` pair, so
51/// commit-time replay only ever sees these two states — mirroring the
52/// folder backend's [`super::filesystem`] buffer shape.
53enum PendingState {
54    Upsert(Vec<u8>),
55    Delete,
56}
57
58/// Every byte the mem holds, behind one lock. Committed bytes are
59/// the in-RAM analogue of the folder backend's on-disk directory; the
60/// pending buffer folds into them on [`commit`](MemBackend::commit).
61#[derive(Default)]
62struct State {
63    /// Committed entity bytes keyed by normalised mem-relative path
64    /// (forward-slash). The in-RAM stand-in for the folder backend's
65    /// directory tree.
66    committed: HashMap<String, Vec<u8>>,
67    /// Staged mutations not yet committed. Cleared on commit or
68    /// [`discard_pending`](MemBackend::discard_pending).
69    pending: HashMap<String, PendingState>,
70    /// Append-only provenance log. The folder backend persists this as
71    /// JSONL under `.memstead/changes.jsonl`; here it is a `Vec` in RAM.
72    provenance: Vec<Provenance>,
73    /// Per-mem `.memstead/config.json` bytes once written. `None`
74    /// until the create path writes the config.
75    config: Option<Vec<u8>>,
76}
77
78/// Writable mem backend whose entire state lives in memory. See the
79/// module docs for the contract it shares with the folder backend.
80pub struct InMemoryBackend {
81    state: Mutex<State>,
82}
83
84impl InMemoryBackend {
85    /// Build an empty in-memory mem. Nothing is provisioned on disk;
86    /// the mem exists only for as long as this backend is held.
87    pub fn new() -> Self {
88        Self {
89            state: Mutex::new(State::default()),
90        }
91    }
92
93    fn lock(&self) -> Result<std::sync::MutexGuard<'_, State>, BackendError> {
94        self.state
95            .lock()
96            .map_err(|_| BackendError::Other("in-memory backend state poisoned".to_string()))
97    }
98}
99
100impl Default for InMemoryBackend {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl MemBackend for InMemoryBackend {
107    fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
108        // Committed state only — pending writes are not listable until
109        // they commit, matching the folder backend (which walks the
110        // on-disk tree and ignores its in-memory buffer). Entity-bearing
111        // `.md` files outside the `.memstead/` umbrella only, so the
112        // engine-owned anchors sidecar (`.memstead/anchors.json`) — and
113        // any other `.memstead/` member — is never mis-parsed as an
114        // entity. Mirrors the git-branch and folder backends' filters.
115        let state = self.lock()?;
116        Ok(state
117            .committed
118            .keys()
119            .filter(|k| k.ends_with(".md") && !k.starts_with(".memstead/"))
120            .map(PathBuf::from)
121            .collect())
122    }
123
124    fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
125        let key = normalise_rel_path(rel_path)?;
126        let state = self.lock()?;
127        // Pending buffer wins over committed — a read after an
128        // uncommitted write sees the staged bytes, and a read after a
129        // staged delete sees `None`. Same ordering as the folder
130        // backend's "pending first, then disk".
131        if let Some(staged) = state.pending.get(&key) {
132            return Ok(match staged {
133                PendingState::Upsert(bytes) => Some(bytes.clone()),
134                PendingState::Delete => None,
135            });
136        }
137        Ok(state.committed.get(&key).cloned())
138    }
139
140    fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), BackendError> {
141        let key = normalise_rel_path(rel_path)?;
142        let mut state = self.lock()?;
143        state
144            .pending
145            .insert(key, PendingState::Upsert(content.to_vec()));
146        Ok(())
147    }
148
149    fn delete_entity(&self, rel_path: &Path) -> Result<(), BackendError> {
150        let key = normalise_rel_path(rel_path)?;
151        let mut state = self.lock()?;
152        state.pending.insert(key, PendingState::Delete);
153        Ok(())
154    }
155
156    fn move_entity(&self, from: &Path, to: &Path) -> Result<(), BackendError> {
157        let from_key = normalise_rel_path(from)?;
158        let to_key = normalise_rel_path(to)?;
159        let mut state = self.lock()?;
160
161        // Resolve the source bytes from the pending buffer if staged,
162        // otherwise from committed state — mirroring the folder
163        // backend's `read_source`.
164        let bytes = match state.pending.remove(&from_key) {
165            Some(PendingState::Upsert(b)) => b,
166            Some(PendingState::Delete) => {
167                state.pending.insert(from_key, PendingState::Delete);
168                return Err(BackendError::Other(format!(
169                    "move source {} is already pending deletion",
170                    from.display()
171                )));
172            }
173            None => match state.committed.get(&from_key) {
174                Some(b) => b.clone(),
175                None => {
176                    return Err(BackendError::Other(format!(
177                        "move source {} does not exist",
178                        from.display()
179                    )));
180                }
181            },
182        };
183
184        if matches!(state.pending.get(&to_key), Some(PendingState::Upsert(_))) {
185            return Err(BackendError::Other(format!(
186                "move target {} already has a pending write",
187                to.display()
188            )));
189        }
190        state.pending.insert(from_key, PendingState::Delete);
191        state.pending.insert(to_key, PendingState::Upsert(bytes));
192        Ok(())
193    }
194
195    fn discard_pending(&self) -> Result<(), BackendError> {
196        let mut state = self.lock()?;
197        state.pending.clear();
198        Ok(())
199    }
200
201    fn commit(&self, _message: &str, _ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
202        let mut state = self.lock()?;
203        let ops: Vec<(String, PendingState)> = state.pending.drain().collect();
204        for (key, op) in ops {
205            match op {
206                PendingState::Upsert(bytes) => {
207                    state.committed.insert(key, bytes);
208                }
209                PendingState::Delete => {
210                    state.committed.remove(&key);
211                }
212            }
213        }
214        Ok(make_commit_id())
215    }
216
217    fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError> {
218        let mut state = self.lock()?;
219        state.provenance.push(record.clone());
220        Ok(())
221    }
222
223    fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
224        let state = self.lock()?;
225        // Cursor is an opaque RFC-3339 timestamp string, same as the
226        // folder backend's `ts` field. String compare matches its
227        // `ts_str <= c` filter — lexical order of RFC-3339 UTC strings
228        // is chronological.
229        let out = state
230            .provenance
231            .iter()
232            .filter(|r| match cursor {
233                Some(c) => format_rfc3339_utc(r.timestamp).as_str() > c,
234                None => true,
235            })
236            .cloned()
237            .collect();
238        Ok(out)
239    }
240
241    fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
242        // Read via the entity path so pending-buffer precedence applies —
243        // a staged sidecar write is visible before its commit, symmetric
244        // with the git-branch backend.
245        self.read_entity(Path::new(crate::anchor::ANCHOR_SIDECAR_PATH))
246    }
247
248    fn write_anchors_sidecar(&self, bytes: &[u8]) -> Result<(), BackendError> {
249        // Stage into the same pending buffer entity writes use, so the
250        // next commit folds entity + sidecar together. `list_entities`
251        // filters `.memstead/`, so the sidecar never lists as an entity.
252        self.write_entity(Path::new(crate::anchor::ANCHOR_SIDECAR_PATH), bytes)
253    }
254
255    fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
256        let state = self.lock()?;
257        Ok(state.config.clone())
258    }
259
260    fn write_mem_config(&self, bytes: &[u8]) -> Result<(), BackendError> {
261        // Writable backend — unlike the sealed archive (whose default
262        // impl returns `Sealed`), the in-memory mem stores the config
263        // so the create path can write it and boot can read it back.
264        let mut state = self.lock()?;
265        state.config = Some(bytes.to_vec());
266        Ok(())
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::provenance::ProvenanceKind;
274    use crate::vcs::{Actor, ClientId, CommitContext};
275    use std::time::{Duration, UNIX_EPOCH};
276
277    fn ctx<'a>() -> CommitContext<'a> {
278        CommitContext {
279            actor: Actor::Cli,
280            client: Some(ClientId {
281                name: "claude-code".to_string(),
282                version: "0.1.0".to_string(),
283            }),
284            tool: Some("test"),
285            note: None,
286            role: Default::default(),
287            logical_operation_id: None,
288            entity_ids: None,
289        }
290    }
291
292    #[test]
293    fn write_then_commit_round_trips_in_ram() {
294        let b = InMemoryBackend::new();
295        b.write_entity(Path::new("notes/hello.md"), b"# hi\n")
296            .unwrap();
297        let id = b.commit("c1", &ctx()).unwrap();
298        assert!(!id.is_empty());
299        assert_eq!(
300            b.read_entity(Path::new("notes/hello.md")).unwrap(),
301            Some(b"# hi\n".to_vec())
302        );
303        assert_eq!(
304            b.list_entities().unwrap(),
305            vec![PathBuf::from("notes/hello.md")]
306        );
307    }
308
309    #[test]
310    fn read_sees_pending_write_before_commit() {
311        let b = InMemoryBackend::new();
312        b.write_entity(Path::new("a.md"), b"staged").unwrap();
313        // Visible to read, not yet to list (uncommitted).
314        assert_eq!(
315            b.read_entity(Path::new("a.md")).unwrap(),
316            Some(b"staged".to_vec())
317        );
318        assert!(b.list_entities().unwrap().is_empty());
319    }
320
321    #[test]
322    fn delete_removes_committed_path() {
323        let b = InMemoryBackend::new();
324        b.write_entity(Path::new("a.md"), b"a").unwrap();
325        b.write_entity(Path::new("b.md"), b"b").unwrap();
326        b.commit("seed", &ctx()).unwrap();
327        b.delete_entity(Path::new("a.md")).unwrap();
328        b.commit("drop", &ctx()).unwrap();
329        assert_eq!(b.read_entity(Path::new("a.md")).unwrap(), None);
330        assert_eq!(
331            b.read_entity(Path::new("b.md")).unwrap(),
332            Some(b"b".to_vec())
333        );
334    }
335
336    #[test]
337    fn delete_of_missing_path_is_idempotent() {
338        let b = InMemoryBackend::new();
339        b.delete_entity(Path::new("ghost.md")).unwrap();
340        b.commit("noop", &ctx()).unwrap();
341        assert_eq!(b.read_entity(Path::new("ghost.md")).unwrap(), None);
342    }
343
344    #[test]
345    fn move_renames_committed_path() {
346        let b = InMemoryBackend::new();
347        b.write_entity(Path::new("from.md"), b"payload").unwrap();
348        b.commit("seed", &ctx()).unwrap();
349        b.move_entity(Path::new("from.md"), Path::new("nested/to.md"))
350            .unwrap();
351        b.commit("rename", &ctx()).unwrap();
352        assert_eq!(b.read_entity(Path::new("from.md")).unwrap(), None);
353        assert_eq!(
354            b.read_entity(Path::new("nested/to.md")).unwrap(),
355            Some(b"payload".to_vec())
356        );
357    }
358
359    #[test]
360    fn move_carries_pending_upsert_bytes() {
361        let b = InMemoryBackend::new();
362        b.write_entity(Path::new("a.md"), b"alpha").unwrap();
363        b.move_entity(Path::new("a.md"), Path::new("b.md")).unwrap();
364        b.commit("write+move", &ctx()).unwrap();
365        assert_eq!(b.read_entity(Path::new("a.md")).unwrap(), None);
366        assert_eq!(
367            b.read_entity(Path::new("b.md")).unwrap(),
368            Some(b"alpha".to_vec())
369        );
370    }
371
372    #[test]
373    fn move_missing_source_errors() {
374        let b = InMemoryBackend::new();
375        let err = b
376            .move_entity(Path::new("ghost.md"), Path::new("here.md"))
377            .unwrap_err();
378        assert!(matches!(err, BackendError::Other(_)));
379    }
380
381    #[test]
382    fn rejects_path_traversal_absolute_and_empty() {
383        let b = InMemoryBackend::new();
384        // Same rejection rules as the folder backend — reused via
385        // `normalise_rel_path`, surfaced as MemWriter path errors.
386        assert!(b.write_entity(Path::new("../escape.md"), b"x").is_err());
387        assert!(b.write_entity(Path::new("/etc/passwd"), b"x").is_err());
388        assert!(b.write_entity(Path::new(""), b"x").is_err());
389    }
390
391    #[test]
392    fn discard_pending_drops_staged_writes() {
393        let b = InMemoryBackend::new();
394        b.write_entity(Path::new("a.md"), b"first").unwrap();
395        b.commit("seed", &ctx()).unwrap();
396        b.write_entity(Path::new("a.md"), b"second-uncommitted")
397            .unwrap();
398        b.discard_pending().unwrap();
399        b.commit("after-discard", &ctx()).unwrap();
400        // The discarded write never landed; committed bytes unchanged.
401        assert_eq!(
402            b.read_entity(Path::new("a.md")).unwrap(),
403            Some(b"first".to_vec())
404        );
405    }
406
407    #[test]
408    fn reports_no_durable_history() {
409        // Matches the folder backend: no fabricated git-like head.
410        let b = InMemoryBackend::new();
411        assert_eq!(b.current_head().unwrap(), None);
412    }
413
414    #[test]
415    fn mem_config_round_trips() {
416        let b = InMemoryBackend::new();
417        assert_eq!(b.read_mem_config().unwrap(), None);
418        b.write_mem_config(b"{\"schema\":\"default@1.0.0\"}")
419            .unwrap();
420        assert_eq!(
421            b.read_mem_config().unwrap(),
422            Some(b"{\"schema\":\"default@1.0.0\"}".to_vec())
423        );
424    }
425
426    #[test]
427    fn anchors_sidecar_round_trips_and_is_not_listed_as_entity() {
428        let b = InMemoryBackend::new();
429        // No sidecar on a fresh mem.
430        assert_eq!(b.read_anchors_sidecar().unwrap(), None);
431        // Seed a real entity so list has something to filter against.
432        b.write_entity(Path::new("x.md"), b"# x\n").unwrap();
433        b.write_anchors_sidecar(b"{\"version\":1,\"entities\":{}}")
434            .unwrap();
435        // Staged sidecar is visible before commit (pending precedence).
436        assert_eq!(
437            b.read_anchors_sidecar().unwrap(),
438            Some(b"{\"version\":1,\"entities\":{}}".to_vec())
439        );
440        b.commit("seed+anchors", &ctx()).unwrap();
441        // Survives the commit.
442        assert_eq!(
443            b.read_anchors_sidecar().unwrap(),
444            Some(b"{\"version\":1,\"entities\":{}}".to_vec())
445        );
446        // The sidecar never surfaces as an entity.
447        assert_eq!(
448            b.list_entities().unwrap(),
449            vec![PathBuf::from("x.md")],
450            "anchors sidecar must not list as an entity"
451        );
452    }
453
454    #[test]
455    fn provenance_appends_and_reads_with_cursor() {
456        let b = InMemoryBackend::new();
457        let t0 = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
458        let t1 = UNIX_EPOCH + Duration::from_secs(1_700_000_001);
459        b.append_provenance(&Provenance::new(
460            t0,
461            ProvenanceKind::Create,
462            Some("specs--a".to_string()),
463            Actor::Cli,
464            None,
465            None,
466        ))
467        .unwrap();
468        b.append_provenance(&Provenance::new(
469            t1,
470            ProvenanceKind::Update,
471            Some("specs--a".to_string()),
472            Actor::Cli,
473            None,
474            None,
475        ))
476        .unwrap();
477
478        // No cursor → both records.
479        assert_eq!(b.read_provenance(None).unwrap().len(), 2);
480        // Cursor at t0 → only the strictly-later t1 record.
481        let cursor = format_rfc3339_utc(t0);
482        let after = b.read_provenance(Some(&cursor)).unwrap();
483        assert_eq!(after.len(), 1);
484        assert_eq!(after[0].kind, ProvenanceKind::Update);
485    }
486}