Skip to main content

rig_memory_policy/
store.rs

1//! Backend-agnostic memory-store capability traits.
2//!
3//! These traits express the **lowest common denominator** of what a memory
4//! backend must provide so that lifecycle helpers (persistence hooks,
5//! compactors, demotion policies) can be generic over the storage engine.
6//!
7//! ## Scope
8//!
9//! Phase 2 of the [`rig-memvid` extraction
10//! plan](https://github.com/ForeverAngry/rig-memvid/issues/28) audited
11//! `MemvidStore` and `InMemoryStore<E>` side-by-side. The two had **zero**
12//! intersecting public method signatures: payloads differ (`&str` vs
13//! `E: Episode`), commit semantics differ (durable vs append-only memory),
14//! search semantics differ (semantic vs lexical), and error types differ.
15//!
16//! Forcing a unifying base trait would either flatten meaningful
17//! differences or balloon associated types past the point of usefulness.
18//! Instead, this module ships only the **two operations** every
19//! durable-text backend genuinely shares — write a `&str` with
20//! backend-specific options, and commit buffered writes. Backend-specific
21//! surface (memory cards, entity graphs, vector search) remains inherent
22//! on each store and is **not** part of this trait family.
23//!
24//! ## Async-ness
25//!
26//! Both traits use `async fn` in trait (stable since Rust 1.75; MSRV 1.89
27//! satisfies this). Backends whose underlying API is synchronous can
28//! implement the trait by wrapping the sync call in an `async` block at
29//! zero cost. Generic consumers that require `Send`-bounded futures (e.g.
30//! when spawning) can add `where <S as TextWriter>::write_text(..): Send`
31//! style bounds at the call site, or use the `trait_variant` macro
32//! pattern in a follow-up release.
33//!
34//! ## Example
35//!
36//! ```rust
37//! use rig_memory_policy::store::{Committable, TextWriter};
38//! use std::sync::Mutex;
39//!
40//! struct StringBuf {
41//!     pending: Mutex<Vec<String>>,
42//!     committed: Mutex<Vec<String>>,
43//! }
44//!
45//! #[derive(Debug, thiserror::Error)]
46//! #[error("lock poisoned")]
47//! struct BufErr;
48//!
49//! impl TextWriter for StringBuf {
50//!     type Options = ();
51//!     type Id = usize;
52//!     type Error = BufErr;
53//!     async fn write_text(&self, text: &str, _: ()) -> Result<usize, BufErr> {
54//!         let mut guard = self.pending.lock().map_err(|_| BufErr)?;
55//!         guard.push(text.to_owned());
56//!         Ok(guard.len() - 1)
57//!     }
58//! }
59//!
60//! impl Committable for StringBuf {
61//!     type Error = BufErr;
62//!     async fn commit(&self) -> Result<(), BufErr> {
63//!         let mut pending = self.pending.lock().map_err(|_| BufErr)?;
64//!         let mut committed = self.committed.lock().map_err(|_| BufErr)?;
65//!         committed.append(&mut pending);
66//!         Ok(())
67//!     }
68//! }
69//! ```
70
71/// A backend that accepts text writes keyed by some backend-defined id.
72///
73/// Implementations may buffer writes until [`Committable::commit`] is called.
74/// Whether `write_text` is durable on return is backend-defined; consumers
75/// that need a durability guarantee should always pair `write_text` with
76/// `commit`.
77///
78/// `Options` carries backend-specific per-write metadata (tags, scope,
79/// dedup hints, frame envelope, etc.). Backends with no per-write options
80/// should use `type Options = ();`.
81pub trait TextWriter {
82    /// Backend-specific options accompanying each write.
83    type Options;
84    /// Backend-specific identifier returned per successful write.
85    type Id;
86    /// Error type for failed writes.
87    type Error: core::error::Error + Send + Sync + 'static;
88
89    /// Buffer or persist a text payload with the given options, returning
90    /// the backend's identifier for the resulting entry.
91    ///
92    /// The returned future is **not** required to be `Send`; consumers
93    /// that need to spawn it across threads must add an explicit `Send`
94    /// bound at the call site.
95    fn write_text(
96        &self,
97        text: &str,
98        options: Self::Options,
99    ) -> impl core::future::Future<Output = Result<Self::Id, Self::Error>>;
100}
101
102/// A backend that supports an explicit commit step to durably persist
103/// previously buffered writes.
104///
105/// Backends that persist on every write may still implement `Committable`
106/// as a no-op so they can be used interchangeably in generic code.
107pub trait Committable {
108    /// Error type for failed commits.
109    type Error: core::error::Error + Send + Sync + 'static;
110
111    /// Flush any pending writes to durable storage.
112    fn commit(&self) -> impl core::future::Future<Output = Result<(), Self::Error>>;
113}
114
115#[cfg(test)]
116#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
117mod tests {
118    use super::*;
119    use std::sync::Mutex;
120
121    #[derive(Debug, thiserror::Error)]
122    #[error("test backend error")]
123    struct TestErr;
124
125    struct CountingStore {
126        pending: Mutex<Vec<String>>,
127        committed: Mutex<Vec<String>>,
128    }
129
130    impl CountingStore {
131        fn new() -> Self {
132            Self {
133                pending: Mutex::new(Vec::new()),
134                committed: Mutex::new(Vec::new()),
135            }
136        }
137    }
138
139    impl TextWriter for CountingStore {
140        type Options = ();
141        type Id = u64;
142        type Error = TestErr;
143        async fn write_text(&self, text: &str, _: ()) -> Result<u64, TestErr> {
144            let mut guard = self.pending.lock().map_err(|_| TestErr)?;
145            guard.push(text.to_owned());
146            Ok((guard.len() - 1) as u64)
147        }
148    }
149
150    impl Committable for CountingStore {
151        type Error = TestErr;
152        async fn commit(&self) -> Result<(), TestErr> {
153            let mut pending = self.pending.lock().map_err(|_| TestErr)?;
154            let mut committed = self.committed.lock().map_err(|_| TestErr)?;
155            committed.append(&mut pending);
156            Ok(())
157        }
158    }
159
160    /// Toy generic helper proving a hook-like function can be written
161    /// against the trait pair alone (no backend knowledge).
162    async fn persist_all<S>(
163        store: &S,
164        lines: &[&str],
165    ) -> Result<Vec<<S as TextWriter>::Id>, <S as TextWriter>::Error>
166    where
167        S: TextWriter<Options = ()> + Committable<Error = <S as TextWriter>::Error>,
168    {
169        let mut ids = Vec::with_capacity(lines.len());
170        for line in lines {
171            ids.push(store.write_text(line, ()).await?);
172        }
173        store.commit().await?;
174        Ok(ids)
175    }
176
177    #[test]
178    fn write_then_commit_moves_pending_to_committed() {
179        let store = CountingStore::new();
180        let ids = pollster::block_on(persist_all(&store, &["alpha", "beta", "gamma"])).unwrap();
181        assert_eq!(ids, vec![0, 1, 2]);
182        assert!(store.pending.lock().unwrap().is_empty());
183        assert_eq!(
184            store.committed.lock().unwrap().clone(),
185            vec!["alpha".to_owned(), "beta".to_owned(), "gamma".to_owned()],
186        );
187    }
188
189    #[test]
190    fn write_without_commit_keeps_pending() {
191        let store = CountingStore::new();
192        let id = pollster::block_on(store.write_text("orphan", ())).unwrap();
193        assert_eq!(id, 0);
194        assert_eq!(store.pending.lock().unwrap().len(), 1);
195        assert!(store.committed.lock().unwrap().is_empty());
196    }
197}