Skip to main content

mnesis_store/
state.rs

1use alloc::vec::Vec;
2use core::future::Future;
3use core::num::{NonZeroU32, NonZeroU64};
4
5use mnesis::{Id, Version};
6
7use crate::codec::{Decode, Encode, OwningCodec};
8
9// ═══════════════════════════════════════════════════════════════════════════
10// SnapshotStore<S, P> — atomic state + position persistence
11// ═══════════════════════════════════════════════════════════════════════════
12
13/// Outcome of [`SnapshotStore::hydrate`] — a three-state answer that keeps
14/// "nothing saved" distinct from "saved, but stale".
15///
16/// The distinction is invisible to an aggregate snapshot (both mean "replay the
17/// stream"), but load-bearing for a projection: [`Absent`](Self::Absent) is a
18/// brand-new projection expected to start empty, whereas [`Stale`](Self::Stale)
19/// means an existing projection was invalidated by a schema bump and the very
20/// next thing that happens is a **full re-fold of the whole `$all` stream**. On
21/// a mobile/`IoT` host that re-fold can be a long, battery-heavy operation, so
22/// the host must be able to see it coming (warn, defer to Wi-Fi/charging,
23/// throttle) — collapsing both into `None` would hide it.
24///
25/// There is deliberately no `stored_state` on `Stale`: derived state has no
26/// upcasting path (a schema change forces a rebuild, it cannot be migrated), so
27/// carrying the old bytes would only invite a migration that does not exist.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Hydrated<S, P> {
30    /// Nothing has ever been saved for this id — start from scratch.
31    Absent,
32    /// A snapshot exists but under a *different* schema version; it cannot be
33    /// decoded into the requested shape, so the caller must rebuild from the
34    /// log. Carries the schema version that was found, for observability.
35    Stale {
36        /// The schema version the stored snapshot was written under.
37        stored_schema: NonZeroU32,
38    },
39    /// A snapshot at the requested schema version.
40    Found {
41        /// The position the state was folded up to.
42        position: P,
43        /// The restored state.
44        state: S,
45    },
46}
47
48impl<S, P> Hydrated<S, P> {
49    /// The restored `(position, state)` when a snapshot at the requested schema
50    /// version was found; `None` for `Absent` or `Stale` (both mean "rebuild").
51    ///
52    /// A convenience for callers that treat absent and stale identically (e.g.
53    /// the aggregate-snapshot decorator, which replays the stream either way).
54    /// Callers that must tell the two apart — projection hosts — match the enum.
55    #[must_use]
56    pub fn into_found(self) -> Option<(P, S)> {
57        match self {
58            Self::Found { position, state } => Some((position, state)),
59            Self::Absent | Self::Stale { .. } => None,
60        }
61    }
62}
63
64/// Atomic persistence of a snapshot — derived state plus the position it
65/// was folded up to.
66///
67/// One trait, two callers:
68/// - aggregate snapshots — the aggregate's state, at its `Version`.
69/// - projections — the projection's state, at its position.
70///
71/// State and position are saved and loaded *together*. A half-write
72/// (state without position, or position without state) is impossible:
73/// the trait exposes only the two *combined* operations, never "save
74/// state alone". Atomicity itself is the adapter's responsibility — it
75/// owns both the state and position storage and commits them in one
76/// transaction.
77///
78/// Generic over the position type `P` so one trait serves a single
79/// stream (`P = Version`) and a multi-stream, single-producer projection
80/// (`P =` the adapter's [`AllPosition`](crate::AllPosition)).
81pub trait SnapshotStore<S, P>: Send + Sync {
82    /// Adapter-specific error type.
83    type Error: core::error::Error + Send + Sync + 'static;
84
85    /// Load the saved state and position from a single consistent snapshot.
86    ///
87    /// Returns [`Hydrated::Found`] with the state and position when a snapshot
88    /// at `schema_version` exists; [`Hydrated::Stale`] when one exists under a
89    /// different schema version (caller must rebuild); [`Hydrated::Absent`] when
90    /// nothing has been saved. `Stale` and `Absent` are kept distinct so a
91    /// projection host can tell a fresh start from a schema-bump rebuild.
92    ///
93    /// # Errors
94    ///
95    /// Returns `Self::Error` if the underlying store fails to read.
96    fn hydrate(
97        &self,
98        id: &impl Id,
99        schema_version: NonZeroU32,
100    ) -> impl Future<Output = Result<Hydrated<S, P>, Self::Error>> + Send;
101
102    /// Save state and position together, in a single transaction.
103    ///
104    /// Either both are durably stored, or neither is.
105    ///
106    /// # Errors
107    ///
108    /// Returns `Self::Error` if the underlying store fails to commit.
109    fn commit(
110        &self,
111        id: &impl Id,
112        schema_version: NonZeroU32,
113        position: P,
114        state: &S,
115    ) -> impl Future<Output = Result<(), Self::Error>> + Send;
116}
117
118// ═══════════════════════════════════════════════════════════════════════════
119// Delegation implementation — share via reference
120// ═══════════════════════════════════════════════════════════════════════════
121
122impl<S, P, T> SnapshotStore<S, P> for &T
123where
124    S: Send + Sync,
125    P: Send,
126    T: SnapshotStore<S, P>,
127{
128    type Error = T::Error;
129
130    fn hydrate(
131        &self,
132        id: &impl Id,
133        schema_version: NonZeroU32,
134    ) -> impl Future<Output = Result<Hydrated<S, P>, Self::Error>> + Send {
135        (**self).hydrate(id, schema_version)
136    }
137
138    fn commit(
139        &self,
140        id: &impl Id,
141        schema_version: NonZeroU32,
142        position: P,
143        state: &S,
144    ) -> impl Future<Output = Result<(), Self::Error>> + Send {
145        (**self).commit(id, schema_version, position, state)
146    }
147}
148
149// ═══════════════════════════════════════════════════════════════════════════
150// PersistTrigger — when-to-persist policy
151// ═══════════════════════════════════════════════════════════════════════════
152
153/// Strategy for deciding when to persist state.
154///
155/// Used by both projection steppers (when to checkpoint projection state)
156/// and snapshot decorators (when to snapshot aggregate state).
157///
158/// Generic over the position type `P` (default [`Version`]) for the same
159/// reason [`SnapshotStore`] is: a per-stream caller paces on [`Version`],
160/// an `$all` projection paces on the adapter's
161/// [`AllPosition`](crate::AllPosition). Position-agnostic triggers
162/// ([`AfterEventTypes`]) implement it for every `P`; arithmetic triggers
163/// ([`EveryNEvents`]) only for [`Version`] — a composite `$all` position
164/// (e.g. postgres `(txid, seq)`) deliberately has no bucket arithmetic, so
165/// an `$all` pacer is a custom impl on the adapter's concrete position.
166pub trait PersistTrigger<P = Version>: Send + Sync {
167    /// Whether state should be persisted now.
168    ///
169    /// - `old_position`: the reference position the caller last persisted at
170    ///   (`None` for first run) — the snapshot decorator passes the version
171    ///   just before this save, the projection stepper its last checkpoint
172    /// - `new_position`: position after the operation
173    /// - `event_names`: names of events just processed
174    fn should_persist(
175        &self,
176        old_position: Option<P>,
177        new_position: P,
178        event_names: impl Iterator<Item: AsRef<str>>,
179    ) -> bool;
180}
181
182/// Persist every N events (bucket-crossing algorithm).
183#[derive(Debug, Clone, Copy)]
184pub struct EveryNEvents(pub NonZeroU64);
185
186impl PersistTrigger for EveryNEvents {
187    fn should_persist(
188        &self,
189        old_version: Option<Version>,
190        new_version: Version,
191        _event_names: impl Iterator<Item: AsRef<str>>,
192    ) -> bool {
193        let n = self.0.get();
194        let old_bucket = old_version.map_or(0, |v| v.as_u64() / n);
195        let new_bucket = new_version.as_u64() / n;
196        new_bucket > old_bucket
197    }
198}
199
200/// Persist after specific event types.
201#[derive(Debug, Clone)]
202pub struct AfterEventTypes {
203    types: Vec<&'static str>,
204}
205
206impl AfterEventTypes {
207    /// Create a trigger that fires when any of the given event types is persisted.
208    #[must_use]
209    pub fn new(types: &[&'static str]) -> Self {
210        Self {
211            types: types.to_vec(),
212        }
213    }
214}
215
216impl<P> PersistTrigger<P> for AfterEventTypes {
217    fn should_persist(
218        &self,
219        _old_position: Option<P>,
220        _new_position: P,
221        mut event_names: impl Iterator<Item: AsRef<str>>,
222    ) -> bool {
223        event_names.any(|name| self.types.iter().any(|t| *t == name.as_ref()))
224    }
225}
226
227// ═══════════════════════════════════════════════════════════════════════════
228// CodecSnapshotStore<SS, C> — byte-level <-> typed bridge via Encode + Decode
229// ═══════════════════════════════════════════════════════════════════════════
230
231/// Adapter that bridges a byte-level [`SnapshotStore<Vec<u8>, P>`] to a typed
232/// [`SnapshotStore<S, P>`] by encoding/decoding through an [`Encode<S>`] +
233/// [`Decode<S>`] pair.
234///
235/// Use this when your storage backend works with raw bytes (e.g., fjall)
236/// but consumers need typed state. The position `P` is opaque to the
237/// bridge — it passes through untouched.
238pub struct CodecSnapshotStore<SS, C> {
239    store: SS,
240    codec: C,
241}
242
243impl<SS, C> CodecSnapshotStore<SS, C> {
244    /// Create a new codec-bridged snapshot store.
245    #[must_use]
246    pub const fn new(store: SS, codec: C) -> Self {
247        Self { store, codec }
248    }
249}
250
251impl<S, P, SS, C> SnapshotStore<S, P> for CodecSnapshotStore<SS, C>
252where
253    S: Send + Sync + 'static,
254    P: Send,
255    SS: SnapshotStore<Vec<u8>, P>,
256    C: Encode<S> + OwningCodec<S>,
257{
258    type Error =
259        CodecSnapshotStoreError<SS::Error, <C as Encode<S>>::Error, <C as Decode<S>>::Error>;
260
261    async fn hydrate(
262        &self,
263        id: &impl Id,
264        schema_version: NonZeroU32,
265    ) -> Result<Hydrated<S, P>, Self::Error> {
266        // Absent/Stale pass through untouched — only `Found` carries bytes to
267        // decode. The position `P` and the schema-version signal are opaque to
268        // the bridge.
269        let (position, bytes) = match self
270            .store
271            .hydrate(id, schema_version)
272            .await
273            .map_err(CodecSnapshotStoreError::Store)?
274        {
275            Hydrated::Absent => return Ok(Hydrated::Absent),
276            Hydrated::Stale { stored_schema } => return Ok(Hydrated::Stale { stored_schema }),
277            Hydrated::Found { position, state } => (position, state),
278        };
279
280        let label = id.to_label();
281        // Wrap the snapshot's raw bytes in a synthetic envelope so the
282        // codec's envelope-based decode trait can be called. The
283        // snapshot wire format is *not* the event wire format; this
284        // synthesis only carries the bytes through to `decode()`.
285        let env = crate::envelope::PersistedEnvelope::for_decode(label.as_str(), &bytes)
286            .map_err(CodecSnapshotStoreError::EnvelopeSynthesis)?;
287        let state =
288            <C as Decode<S>>::decode(&self.codec, &env).map_err(CodecSnapshotStoreError::Decode)?;
289
290        Ok(Hydrated::Found { position, state })
291    }
292
293    async fn commit(
294        &self,
295        id: &impl Id,
296        schema_version: NonZeroU32,
297        position: P,
298        state: &S,
299    ) -> Result<(), Self::Error> {
300        let bytes = <C as Encode<S>>::encode(&self.codec, state)
301            .map_err(CodecSnapshotStoreError::Encode)?;
302
303        // SnapshotStore<Vec<u8>, P> requires &Vec<u8>; adapt by copying.
304        // Snapshot writes are rare relative to the read path, so the
305        // extra allocation here is acceptable.
306        let bytes_vec = bytes.to_vec();
307        self.store
308            .commit(id, schema_version, position, &bytes_vec)
309            .await
310            .map_err(CodecSnapshotStoreError::Store)
311    }
312}
313
314/// Error from [`CodecSnapshotStore`] — the underlying store, the encoder, the decoder,
315/// or the wire-format synthesis used to call the envelope-based decode trait.
316#[derive(Debug, thiserror::Error)]
317#[non_exhaustive]
318pub enum CodecSnapshotStoreError<S, EncErr, DecErr> {
319    /// The underlying byte-level store failed.
320    #[error(transparent)]
321    Store(S),
322    /// Encoding failed.
323    #[error(transparent)]
324    Encode(EncErr),
325    /// Decoding failed.
326    #[error(transparent)]
327    Decode(DecErr),
328    /// Wire-format synthesis failed while wrapping the snapshot bytes in
329    /// an envelope for the codec. Practically unreachable for in-budget
330    /// labels (≤64 bytes via `Id::to_label`) and snapshot bytes ≤ 4 GiB.
331    #[error("envelope synthesis error: {0}")]
332    EnvelopeSynthesis(#[source] crate::envelope::ForDecodeError),
333}