Skip to main content

mnesis_store/
projection.rs

1use core::iter;
2use core::num::NonZeroU32;
3
4use mnesis::{DomainEvent, Id, Version};
5
6use crate::decoded::Decoded;
7use crate::state::{Hydrated, PersistTrigger, SnapshotStore};
8use crate::store::AllPosition;
9use crate::stream_id::StreamKey;
10
11/// A pure fold function over domain events.
12///
13/// Processes events one at a time to produce derived state. The
14/// framework handles all IO (reading events, persisting state,
15/// checkpointing). The projector is only responsible for computation.
16///
17/// Fallible: `apply` returns `Result` because projections may perform
18/// checked arithmetic or encounter domain-specific edge cases.
19/// Recovery policy (skip, fail, dead-letter) is handled by middleware
20/// layers, not the projector itself.
21///
22/// # Comparison with `AggregateState`
23///
24/// `AggregateState::apply` is infallible because an aggregate always
25/// applies its own events. A projector may process events from any
26/// source, and may do derived computations (sums, counts) that can
27/// overflow.
28pub trait Projector: Send + Sync + 'static {
29    /// The domain event type this projector handles.
30    type Event: DomainEvent;
31
32    /// The derived state produced by folding events.
33    type State: Send + Sync + 'static;
34
35    /// Error type for fallible projection logic.
36    type Error: core::error::Error + Send + Sync + 'static;
37
38    /// The initial state before any events have been applied.
39    fn initial(&self) -> Self::State;
40
41    /// Apply a single event to the current state, producing new state.
42    ///
43    /// Must use checked arithmetic for all computations. Return `Err`
44    /// on overflow, underflow, or any domain-specific invariant
45    /// violation. The framework decides recovery policy.
46    ///
47    /// # Errors
48    ///
49    /// Returns `Self::Error` when the event cannot be applied — e.g.,
50    /// arithmetic overflow, underflow, or domain invariant violation.
51    fn apply(&self, state: Self::State, event: &Self::Event) -> Result<Self::State, Self::Error>;
52
53    /// Apply one event together with its origin-stream attribution, when the
54    /// item carries one.
55    ///
56    /// `key` is `Some` iff the event arrived off an `$all` read — the origin
57    /// [`StreamKey`] the store stamps beside every `$all` item (#333). On a
58    /// per-stream fold it is `None`: there the stream id is the query argument
59    /// the caller already holds, and the item carries no tag.
60    ///
61    /// The default ignores the key and delegates to [`apply`](Self::apply), so
62    /// a single-stream projector implements only `apply`. A multi-stream
63    /// projector that routes by origin stream overrides this method instead.
64    /// [`Projection::advance`] calls only this method.
65    ///
66    /// # Errors
67    ///
68    /// Returns `Self::Error` when the event cannot be applied.
69    fn apply_attributed(
70        &self,
71        state: Self::State,
72        key: Option<&StreamKey>,
73        event: &Self::Event,
74    ) -> Result<Self::State, Self::Error> {
75        let _ = key;
76        self.apply(state, event)
77    }
78}
79
80// ═══════════════════════════════════════════════════════════════════════════
81// Positioned — the stepper's input contract over both stream item shapes
82// ═══════════════════════════════════════════════════════════════════════════
83
84mod sealed {
85    pub trait Sealed {}
86}
87
88/// A decoded stream item carrying the position the stepper checkpoints at.
89///
90/// The two shapes a decoded subscription yields (the typed duals of
91/// [`RawItem`](crate::decoded::RawItem)):
92///
93/// - [`Decoded<E>`] (per-stream) — the bookmark is the `version` *inside*
94///   the box; <code>Pos = [Version]</code>.
95/// - `(P, StreamKey, Decoded<E>)` (`$all`) — the bookmark is the
96///   [`AllPosition`] tag riding *beside* the box,
97///   exactly as `.decoded()` yields it; `Pos = P`. The [`StreamKey`] flows to
98///   [`Projector::apply_attributed`]: a multi-stream projector that routes by
99///   origin stream overrides it; the defaulted method ignores the key and
100///   delegates to [`Projector::apply`], so a single-stream projector is
101///   untouched.
102///
103/// Sealed on purpose: the pairing of position and event is **structural**.
104/// A caller can never hand [`Projection::advance`] a position that did not
105/// arrive with the event, so a committed checkpoint always describes the
106/// state it is saved with — the same illegal-states-unrepresentable bet as
107/// the atomic [`SnapshotStore::commit`].
108pub trait Positioned: sealed::Sealed {
109    /// The decoded event type carried by the item.
110    type Event;
111    /// The position type the stepper checkpoints at.
112    type Pos: Copy + Send;
113    /// Split the item into its bookmark, its origin-stream attribution
114    /// (`$all` items only), and the decoded box.
115    fn into_parts(self) -> (Self::Pos, Option<StreamKey>, Decoded<Self::Event>);
116}
117
118impl<E> sealed::Sealed for Decoded<E> {}
119impl<E> Positioned for Decoded<E> {
120    type Event = E;
121    type Pos = Version;
122    fn into_parts(self) -> (Version, Option<StreamKey>, Self) {
123        (self.version, None, self)
124    }
125}
126
127impl<E, P: AllPosition> sealed::Sealed for (P, StreamKey, Decoded<E>) {}
128impl<E, P: AllPosition> Positioned for (P, StreamKey, Decoded<E>) {
129    type Event = E;
130    type Pos = P;
131    fn into_parts(self) -> (P, Option<StreamKey>, Decoded<E>) {
132        (self.0, Some(self.1), self.2)
133    }
134}
135
136// ═══════════════════════════════════════════════════════════════════════════
137// Projection<I, P, Trig, SS> — inert per-event assembly of the four primitives
138// ═══════════════════════════════════════════════════════════════════════════
139
140/// A per-event projection **stepper** — the ergonomic assembly of the four
141/// projection primitives ([`Projector`], [`PersistTrigger`],
142/// [`SnapshotStore`], and — outside this type — a [`Subscription`]).
143///
144/// It owns **no loop**. mnesis still ships no runner: the host drives the
145/// stepper one event at a time. A tokio `while let` calls [`advance`] in its
146/// body; a Bombay/Agency actor calls it from its message handler. Both shrink
147/// to a single `advance` call and share no loop code, so nothing can drift.
148///
149/// The stepper holds only the *bookkeeping* — the last-persisted `checkpoint`
150/// and the folded-but-unpersisted `pending` position — plus the persist
151/// decision. **State flows through the caller**, not the stepper, because
152/// [`Projector::apply`] consumes state by value: `load` hands back the starting
153/// state, [`advance`] returns the next state, and [`flush`] takes the final
154/// state by reference. This keeps the primitive Clone-free and unopinionated —
155/// it decides *when* to persist and tracks *where* you are, and never owns your
156/// read model.
157///
158/// The codec is **absent by construction**: decode the raw subscription with
159/// [`StepStreamExt::decoded`](crate::StepStreamExt) /
160/// [`DecodedStreamExt`](crate::DecodedStreamExt) *before* the event reaches
161/// [`advance`], so this type never names a codec and never restates the
162/// owning-codec `for<'a>` bound.
163///
164/// # Assembly (consumer-owned loop)
165///
166/// Per-stream (`Pos` defaults to [`Version`]):
167/// ```ignore
168/// let (mut proj, mut state) =
169///     Projection::load(id, projector, trigger, &snapshots, schema).await?;
170/// let stream = subscription
171///     .subscribe(proj.id(), proj.checkpoint())?
172///     .events()
173///     .decoded(codec);
174/// tokio::pin!(stream);
175/// while let Some(item) = stream.next().await {
176///     state = proj.advance(state, item?).await?;
177/// }
178/// proj.flush(&state).await?;
179/// ```
180///
181/// `$all` (`Pos` = the adapter's [`AllPosition`]) is the
182/// **same loop** — the `(position, StreamKey, Decoded)` tuple `.decoded()`
183/// yields feeds [`advance`] whole; only the subscribe call and the snapshot
184/// store's position type differ:
185/// ```ignore
186/// let (mut proj, mut state) =
187///     Projection::load(id, projector, trigger, &snapshots, schema).await?;
188/// let stream = subscription
189///     .subscribe_all(proj.checkpoint())?
190///     .events()
191///     .decoded(codec);
192/// tokio::pin!(stream);
193/// while let Some(item) = stream.next().await {
194///     state = proj.advance(state, item?).await?;
195/// }
196/// proj.flush(&state).await?;
197/// ```
198///
199/// [`advance`]: Projection::advance
200/// [`flush`]: Projection::flush
201pub struct Projection<I, P: Projector, Trig, SS, Pos = Version> {
202    id: I,
203    projector: P,
204    trigger: Trig,
205    snapshot_store: SS,
206    schema_version: NonZeroU32,
207    /// Last position durably committed together with the state.
208    checkpoint: Option<Pos>,
209    /// Folded-but-not-yet-persisted tail position, flushed on shutdown.
210    pending: Option<Pos>,
211    /// `Some(old_schema)` iff `load` discarded a snapshot under a different
212    /// schema version — the projection is re-folding from scratch. Surfaced via
213    /// [`rebuilding_from`](Projection::rebuilding_from) so a host can distinguish
214    /// a costly schema-bump rebuild from an ordinary fresh start.
215    rebuilt_from: Option<NonZeroU32>,
216}
217
218impl<I, P, Trig, SS, Pos> Projection<I, P, Trig, SS, Pos>
219where
220    I: Id,
221    P: Projector,
222    Trig: PersistTrigger<Pos>,
223    SS: SnapshotStore<P::State, Pos>,
224    Pos: Copy + Send,
225{
226    /// Assemble and hydrate the stepper, returning it alongside the starting
227    /// state.
228    ///
229    /// Resolves `(state, checkpoint)` from the snapshot store atomically:
230    /// - [`Hydrated::Found`] → restore its state and position (resume).
231    /// - [`Hydrated::Absent`] → [`Projector::initial`], no checkpoint (fresh).
232    /// - [`Hydrated::Stale`] → `initial()`, no checkpoint, and
233    ///   [`rebuilding_from`](Self::rebuilding_from) reports the discarded schema
234    ///   version — a bump invalidated the saved state, so the projection re-folds
235    ///   the whole stream. The host sees that instead of a silent full replay.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`SnapshotStore::Error`] if hydration fails.
240    pub async fn load(
241        id: I,
242        projector: P,
243        trigger: Trig,
244        snapshot_store: SS,
245        schema_version: NonZeroU32,
246    ) -> Result<(Self, P::State), SS::Error> {
247        let (state, checkpoint, rebuilt_from) = match snapshot_store
248            .hydrate(&id, schema_version)
249            .await?
250        {
251            Hydrated::Found { position, state } => (state, Some(position), None),
252            Hydrated::Absent => (projector.initial(), None, None),
253            Hydrated::Stale { stored_schema } => (projector.initial(), None, Some(stored_schema)),
254        };
255        Ok((
256            Self {
257                id,
258                projector,
259                trigger,
260                snapshot_store,
261                schema_version,
262                checkpoint,
263                pending: None,
264                rebuilt_from,
265            },
266            state,
267        ))
268    }
269
270    /// `Some(old_schema)` when [`load`](Self::load) discarded a snapshot written
271    /// under a different schema version — i.e. this projection is re-folding
272    /// from the beginning because of a schema bump, not because it is new.
273    /// `None` means it resumed from a checkpoint or started genuinely fresh
274    /// (disambiguate those two via [`checkpoint`](Self::checkpoint)). A host on a
275    /// constrained device can use this to warn/defer/throttle the rebuild.
276    #[must_use]
277    pub const fn rebuilding_from(&self) -> Option<NonZeroU32> {
278        self.rebuilt_from
279    }
280
281    /// The id this projection is bound to — pass to `subscribe`.
282    pub const fn id(&self) -> &I {
283        &self.id
284    }
285
286    /// The last durably-committed position — pass to `subscribe` (per-stream,
287    /// `Pos = Version`) or `subscribe_all` (`Pos` = the adapter's
288    /// [`AllPosition`]) as the resume point. `None` means
289    /// "from the beginning".
290    pub const fn checkpoint(&self) -> Option<Pos> {
291        self.checkpoint
292    }
293
294    /// Fold one decoded event, then commit `(state, position)` together if the
295    /// [`PersistTrigger`] fires. Returns the new state.
296    ///
297    /// Accepts either item shape a decoded stream yields (see [`Positioned`]):
298    /// a bare [`Decoded<E>`](Decoded) from a per-stream subscription (the
299    /// position is its `version`), or the `(position, StreamKey, Decoded<E>)`
300    /// tuple from an `$all` subscription — fed whole, no unpacking (the stream
301    /// key is forwarded to [`Projector::apply_attributed`]). The item's position
302    /// becomes the candidate checkpoint. On a commit the checkpoint advances
303    /// and the pending tail clears; otherwise the position is remembered as
304    /// `pending` for the next [`flush`](Projection::flush).
305    ///
306    /// # Errors
307    ///
308    /// - [`ProjectionError::Apply`] if the projector rejects the event. The
309    ///   consumed state is not recoverable (the fold owns it by value), so a
310    ///   failed `advance` ends the projection — reload to resume.
311    /// - [`ProjectionError::Commit`] if the snapshot commit fails.
312    pub async fn advance<It>(
313        &mut self,
314        state: P::State,
315        item: It,
316    ) -> Result<P::State, ProjectionError<P::Error, SS::Error>>
317    where
318        It: Positioned<Event = P::Event, Pos = Pos>,
319    {
320        let (position, key, decoded) = item.into_parts();
321        let folded = self
322            .projector
323            .apply_attributed(state, key.as_ref(), &decoded.event)
324            .map_err(ProjectionError::Apply)?;
325
326        if self
327            .trigger
328            .should_persist(self.checkpoint, position, iter::once(decoded.event.name()))
329        {
330            self.commit(position, &folded).await?;
331        } else {
332            self.pending = Some(position);
333        }
334        Ok(folded)
335    }
336
337    /// Commit the folded-but-unpersisted tail, if any.
338    ///
339    /// Call once when the host loop ends (shutdown, passivation) so a state
340    /// folded past the last trigger is not lost. A no-op when nothing is
341    /// pending.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`ProjectionError::Commit`] if the snapshot commit fails.
346    pub async fn flush(
347        &mut self,
348        state: &P::State,
349    ) -> Result<(), ProjectionError<P::Error, SS::Error>> {
350        match self.pending {
351            Some(position) => self.commit(position, state).await,
352            None => Ok(()),
353        }
354    }
355
356    /// Persist `(state, position)` atomically and advance the checkpoint.
357    #[cfg_attr(
358        feature = "tracing",
359        tracing::instrument(
360            name = "mnesis.projection.commit",
361            level = "debug",
362            skip_all,
363            fields(id = %self.id)
364        )
365    )]
366    async fn commit(
367        &mut self,
368        position: Pos,
369        state: &P::State,
370    ) -> Result<(), ProjectionError<P::Error, SS::Error>> {
371        self.snapshot_store
372            .commit(&self.id, self.schema_version, position, state)
373            .await
374            .map_err(ProjectionError::Commit)?;
375        self.checkpoint = Some(position);
376        self.pending = None;
377        Ok(())
378    }
379}
380
381/// Failure from [`Projection::advance`] / [`Projection::flush`] — the fold and
382/// the persist are distinct domains and never share a variant (CLAUDE rule 3).
383#[derive(Debug, thiserror::Error)]
384#[non_exhaustive]
385pub enum ProjectionError<PErr, SErr> {
386    /// The projector rejected the event (overflow, invariant violation, …).
387    #[error("projector failed to apply event")]
388    Apply(#[source] PErr),
389    /// The snapshot store failed to commit `(state, position)`.
390    #[error("snapshot commit failed")]
391    Commit(#[source] SErr),
392}