Skip to main content

tact_memory/store/
mod.rs

1//! Storage contract, backend selection, and shared failures.
2
3#[cfg(feature = "local")]
4mod local;
5#[cfg(feature = "client")]
6mod remote;
7
8#[cfg(all(feature = "client", feature = "local"))]
9use crate::{MemoryAccess, MemorySource, secrets::contains_likely_secret};
10use crate::{
11    MemoryKey, MemoryLimits, MemoryRecord, MemoryScan,
12    server::protocol::{self, ExportCursor, SyncReport},
13};
14#[cfg(feature = "local")]
15pub use local::LocalMemoryStore;
16#[cfg(feature = "client")]
17pub use remote::{RemoteClientError, RemoteMemoryClient, RemoteToken};
18#[cfg(all(feature = "client", feature = "local"))]
19use std::path::PathBuf;
20#[cfg(feature = "local")]
21use std::time::{SystemTime, UNIX_EPOCH};
22use std::{error::Error, future::Future};
23use thiserror::Error;
24
25/// Ordinary operations shared by local and authenticated remote memory backends.
26///
27/// Implementations own their namespace and storage boundary. Returned records are ordered
28/// deterministically by the implementation, direct mutations use key versions as compare-and-swap
29/// preconditions, and each implementation captures its authoritative operation time internally.
30/// Dropping a returned future requests cancellation. Implementations may finish an already-started
31/// atomic storage transaction after cancellation.
32pub trait MemoryStore: Clone + Send + Sync + 'static {
33    /// Searches visible records and records scan telemetry.
34    fn scan(
35        &self,
36        query: &str,
37        limit: usize,
38    ) -> impl Future<Output = Result<MemoryScan, MemoryError>> + Send;
39
40    /// Reads unversioned IDs and versioned keys, recording use telemetry.
41    fn read(
42        &self,
43        ids: &[i64],
44        keys: &[MemoryKey],
45    ) -> impl Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send;
46
47    /// Lists a deterministic window that excludes expired probationary records.
48    ///
49    /// Implementations return at most [`MemoryLimits::records`] records so interactive inspection
50    /// does not grow with the complete shared corpus. Full transfer uses paginated export instead.
51    fn list(&self) -> impl Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send;
52
53    /// Inserts content or compare-and-swap replaces `replacement`.
54    fn put(
55        &self,
56        content: &str,
57        replacement: Option<MemoryKey>,
58    ) -> impl Future<Output = Result<MemoryRecord, MemoryError>> + Send;
59
60    /// Compare-and-swap deletes `key`.
61    ///
62    /// Deleting an already-absent key succeeds, making safe request replay idempotent. An existing
63    /// record with a different version returns [`MemoryError::Conflict`].
64    fn delete(&self, key: MemoryKey) -> impl Future<Output = Result<(), MemoryError>> + Send;
65
66    /// Atomically applies an authoritative full snapshot to the owned namespace.
67    ///
68    /// Records absent from `memories` are deleted. Input is validated before commit, IDs remain
69    /// stable, and future insertions must not reuse IDs observed in the snapshot.
70    fn sync(
71        &self,
72        memories: &[MemoryRecord],
73    ) -> impl Future<Output = Result<SyncReport, MemoryError>> + Send;
74
75    /// Exports one page in stable `(namespace, id)` order after `cursor`.
76    ///
77    /// The page is a point-in-time transaction snapshot. `limit` is clamped to the protocol bound;
78    /// callers continue only with the exact returned cursor. Cancellation before storage work starts
79    /// prevents the operation; an already-started transaction may finish.
80    fn export_page(
81        &self,
82        namespaces: Option<&[String]>,
83        cursor: Option<&ExportCursor>,
84        limit: usize,
85    ) -> impl Future<Output = Result<(Vec<MemoryRecord>, Option<ExportCursor>), MemoryError>> + Send;
86
87    /// Collects a complete bounded export through the paginated storage contract.
88    ///
89    /// The collector rejects non-progressing cursors and stops before retaining more records or
90    /// content than a local store can import.
91    fn export_all(
92        &self,
93        namespaces: Option<&[String]>,
94    ) -> impl Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send {
95        async move {
96            let mut cursor = None;
97            let mut records = Vec::new();
98            let mut content_bytes = 0usize;
99            loop {
100                let (page, next_cursor) = self
101                    .export_page(
102                        namespaces,
103                        cursor.as_ref(),
104                        protocol::MAX_EXPORT_PAGE_RECORDS,
105                    )
106                    .await?;
107                let next_record_count = records
108                    .len()
109                    .checked_add(page.len())
110                    .ok_or(MemoryError::InvalidPagination)?;
111                let page_bytes = page.iter().try_fold(0usize, |total, record| {
112                    total.checked_add(record.content.len())
113                });
114                content_bytes = content_bytes
115                    .checked_add(page_bytes.ok_or(MemoryError::InvalidPagination)?)
116                    .ok_or(MemoryError::InvalidPagination)?;
117                if next_record_count > MemoryLimits::PRODUCTION.records
118                    || content_bytes > MemoryLimits::PRODUCTION.total_content_bytes
119                    || next_cursor
120                        .as_ref()
121                        .is_some_and(|next| cursor.as_ref() == Some(next))
122                    || (page.is_empty() && next_cursor.is_some())
123                {
124                    return Err(MemoryError::InvalidPagination);
125                }
126                records.extend(page);
127                match next_cursor {
128                    Some(next) => cursor = Some(next),
129                    None => return Ok(records),
130                }
131            }
132        }
133    }
134}
135
136/// Runtime-selected local-or-remote memory backend.
137#[cfg(all(feature = "client", feature = "local"))]
138#[derive(Clone, Debug)]
139pub enum SelectedMemoryStore {
140    /// Private local SQLite storage.
141    Local(LocalMemoryStore),
142    /// Authenticated namespaced HTTP storage.
143    Remote(RemoteMemoryClient),
144}
145
146#[cfg(all(feature = "client", feature = "local"))]
147impl SelectedMemoryStore {
148    /// Selects a private local SQLite backend.
149    pub fn local(path: impl Into<PathBuf>) -> Self {
150        Self::Local(LocalMemoryStore::new(path))
151    }
152
153    /// Selects an authenticated remote HTTP backend.
154    pub const fn remote(client: RemoteMemoryClient) -> Self {
155        Self::Remote(client)
156    }
157
158    /// Returns the selected backend kind without performing I/O.
159    pub const fn source(&self) -> MemorySource {
160        match self {
161            Self::Local(_) => MemorySource::Local,
162            Self::Remote(_) => MemorySource::Remote,
163        }
164    }
165
166    /// Returns backend provenance and negotiated remote authorization.
167    pub async fn access(&self) -> Result<MemoryAccess, MemoryError> {
168        match self {
169            Self::Local(_) => Ok(MemoryAccess {
170                source: MemorySource::Local,
171                namespace: None,
172                role: None,
173            }),
174            Self::Remote(client) => Ok(MemoryAccess {
175                source: MemorySource::Remote,
176                namespace: Some(client.namespace().to_owned()),
177                role: Some(client.session().await?),
178            }),
179        }
180    }
181}
182
183#[cfg(all(feature = "client", feature = "local"))]
184impl MemoryStore for SelectedMemoryStore {
185    fn scan(
186        &self,
187        query: &str,
188        limit: usize,
189    ) -> impl Future<Output = Result<MemoryScan, MemoryError>> + Send {
190        async move {
191            match self {
192                Self::Local(store) => MemoryStore::scan(store, query, limit).await,
193                Self::Remote(client) => MemoryStore::scan(client, query, limit).await,
194            }
195        }
196    }
197    fn read(
198        &self,
199        ids: &[i64],
200        keys: &[MemoryKey],
201    ) -> impl Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send {
202        async move {
203            match self {
204                Self::Local(store) => MemoryStore::read(store, ids, keys).await,
205                Self::Remote(client) => MemoryStore::read(client, ids, keys).await,
206            }
207        }
208    }
209    fn list(&self) -> impl Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send {
210        async move {
211            match self {
212                Self::Local(store) => MemoryStore::list(store).await,
213                Self::Remote(client) => MemoryStore::list(client).await,
214            }
215        }
216    }
217    fn put(
218        &self,
219        content: &str,
220        replacement: Option<MemoryKey>,
221    ) -> impl Future<Output = Result<MemoryRecord, MemoryError>> + Send {
222        async move {
223            reject_unsafe(content)?;
224            match self {
225                Self::Local(store) => MemoryStore::put(store, content, replacement).await,
226                Self::Remote(client) => MemoryStore::put(client, content, replacement).await,
227            }
228        }
229    }
230    fn delete(&self, key: MemoryKey) -> impl Future<Output = Result<(), MemoryError>> + Send {
231        async move {
232            match self {
233                Self::Local(store) => MemoryStore::delete(store, key).await,
234                Self::Remote(client) => MemoryStore::delete(client, key).await,
235            }
236        }
237    }
238    fn sync(
239        &self,
240        memories: &[MemoryRecord],
241    ) -> impl Future<Output = Result<SyncReport, MemoryError>> + Send {
242        async move {
243            for memory in memories {
244                reject_unsafe(&memory.content)?;
245            }
246            match self {
247                Self::Local(store) => MemoryStore::sync(store, memories).await,
248                Self::Remote(client) => MemoryStore::sync(client, memories).await,
249            }
250        }
251    }
252    fn export_page(
253        &self,
254        namespaces: Option<&[String]>,
255        cursor: Option<&ExportCursor>,
256        limit: usize,
257    ) -> impl Future<Output = Result<(Vec<MemoryRecord>, Option<ExportCursor>), MemoryError>> + Send
258    {
259        async move {
260            match self {
261                Self::Local(store) => {
262                    MemoryStore::export_page(store, namespaces, cursor, limit).await
263                }
264                Self::Remote(client) => {
265                    MemoryStore::export_page(client, namespaces, cursor, limit).await
266                }
267            }
268        }
269    }
270}
271
272#[cfg(all(feature = "client", feature = "local"))]
273fn reject_unsafe(content: &str) -> Result<(), MemoryError> {
274    if contains_likely_secret(content) {
275        return Err(MemoryError::SecretRejected);
276    }
277    Ok(())
278}
279
280#[cfg(feature = "local")]
281fn current_time_ms() -> i64 {
282    let milliseconds = SystemTime::now()
283        .duration_since(UNIX_EPOCH)
284        .unwrap_or_default()
285        .as_millis();
286    i64::try_from(milliseconds).unwrap_or(i64::MAX)
287}
288
289/// Failure from local storage, remote transport, validation, or optimistic concurrency.
290#[derive(Debug, Error)]
291pub enum MemoryError {
292    /// Content is empty after trimming.
293    #[error("memory content is empty")]
294    EmptyContent,
295    /// One record exceeds its byte bound.
296    #[error("memory content exceeds the {maximum_bytes}-byte limit")]
297    ContentTooLarge {
298        /// Configured maximum bytes per record.
299        maximum_bytes: usize,
300    },
301    /// A scan query exceeds its byte bound.
302    #[error("memory query exceeds the {maximum_bytes}-byte limit")]
303    QueryTooLarge {
304        /// Configured maximum query bytes.
305        maximum_bytes: usize,
306    },
307    /// The record-count bound is exhausted.
308    #[error("memory record capacity of {maximum} was reached")]
309    RecordCapacity {
310        /// Configured maximum records.
311        maximum: usize,
312    },
313    /// The aggregate content-byte bound is exhausted.
314    #[error("memory content capacity of {maximum_bytes} bytes was reached")]
315    ContentCapacity {
316        /// Configured maximum aggregate bytes.
317        maximum_bytes: usize,
318    },
319    /// The backing store reached its configured storage bound.
320    #[error("memory storage capacity was reached")]
321    StorageCapacity,
322    /// Content was rejected as a likely credential or secret.
323    #[error("memory content was rejected as a likely secret")]
324    SecretRejected,
325    /// Equivalent normalized content already exists.
326    #[error("an equivalent memory already exists")]
327    Duplicate,
328    /// The requested record does not exist.
329    #[error("memory was not found")]
330    NotFound,
331    /// A key version or snapshot state is stale.
332    #[error("memory changed since it was read")]
333    Conflict,
334    /// A mutation targeted a namespace not owned by this backend.
335    #[error("memories from other namespaces are read-only")]
336    RemoteReadOnly,
337    /// A store returned an invalid or unbounded pagination sequence.
338    #[error("memory store returned invalid pagination")]
339    InvalidPagination,
340    /// Database schema is newer than this implementation supports.
341    #[error(
342        "memory schema version {found} is unsupported; this build supports version {supported}"
343    )]
344    UnsupportedSchemaVersion {
345        /// Schema version found on disk.
346        found: i64,
347        /// Newest schema supported by this implementation.
348        supported: i64,
349    },
350    /// A backend-specific operation failed.
351    #[error("memory backend operation failed")]
352    Backend {
353        /// Backend-specific failure.
354        #[source]
355        source: Box<dyn Error + Send + Sync>,
356    },
357    /// A backend-specific operation failed transiently and may be retried.
358    #[error("memory backend is temporarily unavailable")]
359    Unavailable {
360        /// Backend-specific transient failure.
361        #[source]
362        source: Box<dyn Error + Send + Sync>,
363    },
364}
365
366impl MemoryError {
367    /// Wraps a permanent or unclassified backend-specific failure.
368    pub fn backend(source: impl Error + Send + Sync + 'static) -> Self {
369        Self::Backend {
370            source: Box::new(source),
371        }
372    }
373
374    /// Wraps a transient backend-specific failure that may succeed when retried.
375    pub fn unavailable(source: impl Error + Send + Sync + 'static) -> Self {
376        Self::Unavailable {
377            source: Box::new(source),
378        }
379    }
380
381    /// Returns whether the backend classified this failure as transient.
382    pub fn is_retryable(&self) -> bool {
383        matches!(self, Self::Unavailable { .. })
384    }
385}
386
387#[cfg(feature = "client")]
388impl From<RemoteClientError> for MemoryError {
389    fn from(source: RemoteClientError) -> Self {
390        match source {
391            error @ (RemoteClientError::Transport | RemoteClientError::Unavailable) => {
392                Self::unavailable(error)
393            }
394            RemoteClientError::ReadOnly | RemoteClientError::NamespaceMismatch => {
395                Self::RemoteReadOnly
396            }
397            RemoteClientError::Rejected { code } => match code {
398                protocol::RemoteErrorCode::QueryTooLarge => Self::QueryTooLarge {
399                    maximum_bytes: MemoryLimits::PRODUCTION.query_bytes,
400                },
401                protocol::RemoteErrorCode::ContentTooLarge => Self::ContentTooLarge {
402                    maximum_bytes: MemoryLimits::PRODUCTION.content_bytes,
403                },
404                protocol::RemoteErrorCode::RecordCapacity => Self::RecordCapacity {
405                    maximum: MemoryLimits::PRODUCTION.records,
406                },
407                protocol::RemoteErrorCode::ContentCapacity => Self::ContentCapacity {
408                    maximum_bytes: MemoryLimits::PRODUCTION.total_content_bytes,
409                },
410                protocol::RemoteErrorCode::Duplicate => Self::Duplicate,
411                protocol::RemoteErrorCode::NotFound => Self::NotFound,
412                protocol::RemoteErrorCode::Conflict => Self::Conflict,
413                protocol::RemoteErrorCode::Forbidden
414                | protocol::RemoteErrorCode::NamespaceMismatch => Self::RemoteReadOnly,
415                protocol::RemoteErrorCode::Unavailable => {
416                    Self::unavailable(RemoteClientError::Rejected { code })
417                }
418                _ => Self::backend(RemoteClientError::Rejected { code }),
419            },
420            error => Self::backend(error),
421        }
422    }
423}