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}