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}