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}