mnesis_store/decoded.rs
1//! Consumer-side typed view over the raw envelope streams (#249).
2//!
3//! [`read_stream`](crate::RawEventStore::read_stream) and
4//! [`read_all`](crate::RawEventStore::read_all) yield **raw**
5//! [`PersistedEnvelope`]s, and [`Subscription`](crate::Subscription) yields
6//! them wrapped in a [`Step`] phase marker — by design (the distributed
7//! multi-consumer contract: each consumer holds its own codec). This module
8//! adds an ergonomic layer on top — it does **not** make the core subscription
9//! typed.
10//!
11//! - [`DecodedStreamExt::decoded`] — for **owning** codecs (JSON, bincode): a
12//! stream of carry-away [`Decoded<E>`] items.
13//! - [`DecodedStreamExt::for_each_decoded`] — for **owning and zero-copy**
14//! codecs (rkyv, bytemuck): an internal-iteration fold that hands the borrowed
15//! window to a closure, so no lending stream is needed.
16//! - [`StepStreamExt`] — the phase-aware surface over a `Step`-tagged
17//! subscription stream: [`.events()`](StepStreamExt::events) drops the phase
18//! (then the two `DecodedStreamExt` methods apply), or
19//! [`.decoded()`](StepStreamExt::decoded) decodes while **keeping** the phase.
20
21use core::future::Future;
22
23use bytes::Bytes;
24use futures::{Stream, StreamExt};
25
26use crate::codec::{Decode, OwningCodec};
27use crate::envelope::PersistedEnvelope;
28use crate::step::Step;
29use crate::stream_id::StreamKey;
30use mnesis::Version;
31
32/// A raw envelope, un-packed: the decoded event plus its resume bookmark and
33/// metadata. `T` is the owned event (`E`) on the stream path, or the borrowed
34/// window (`Decode::Output`) inside a fold closure.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Decoded<T> {
37 /// The decoded event (owned `E`, or a borrowed window).
38 pub event: T,
39 /// The per-stream version — the per-stream resume bookmark.
40 pub version: Version,
41 /// The event's metadata, if any (cheap Arc-shared bytes).
42 pub metadata: Option<Bytes>,
43}
44
45/// A read from the raw stream failed, or the codec failed to decode the payload.
46/// The two failure domains never share a variant (CLAUDE rule 3).
47#[derive(Debug, thiserror::Error)]
48pub enum DecodeStreamError<R, D> {
49 /// The underlying raw stream yielded an error item.
50 #[error("subscription stream read failed")]
51 Read(#[source] R),
52 /// The codec rejected the payload.
53 #[error("event decode failed")]
54 Decode(#[source] D),
55}
56
57/// [`DecodedStreamExt::for_each_decoded`] failure: a raw read, a decode, or the
58/// consumer's own fold closure. Three distinct domains (CLAUDE rule 3).
59#[derive(Debug, thiserror::Error)]
60pub enum FoldDecodedError<R, D, H> {
61 /// The underlying raw stream yielded an error item.
62 #[error("subscription stream read failed")]
63 Read(#[source] R),
64 /// The codec rejected the payload.
65 #[error("event decode failed")]
66 Decode(#[source] D),
67 /// The consumer's fold closure returned an error.
68 #[error("decoded-event handler failed")]
69 Handler(#[source] H),
70}
71
72mod sealed {
73 pub trait Sealed {}
74}
75
76/// A raw stream item that carries a [`PersistedEnvelope`] and can be re-tagged
77/// with its decoded counterpart.
78///
79/// Implemented for the two raw shapes the store yields:
80/// - `PersistedEnvelope` (per-stream / `read_stream`) → typed item `Decoded<T>`;
81/// the bookmark (`version`) lives **inside** the box.
82/// - `(P, StreamKey, PersistedEnvelope)` (`$all` / `read_all`) → typed item
83/// `(P, StreamKey, Decoded<T>)`; the `$all` position bookmark and the origin
84/// stream key both stay **beside** the box, preserved through the decode.
85///
86/// The asymmetry is intentional (CLAUDE rule 4): each tag rides exactly
87/// where it lives in the raw layer. Sealed — not implementable downstream.
88pub trait RawItem: sealed::Sealed {
89 /// The typed item once the envelope is decoded to `Decoded<T>`.
90 type Typed<T>;
91 /// Borrow the carried envelope (to decode it).
92 fn envelope(&self) -> &PersistedEnvelope;
93 /// Re-attach the decoded box into this item's shape (copying any tag).
94 fn retag<T>(&self, decoded: Decoded<T>) -> Self::Typed<T>;
95}
96
97impl sealed::Sealed for PersistedEnvelope {}
98impl RawItem for PersistedEnvelope {
99 type Typed<T> = Decoded<T>;
100 fn envelope(&self) -> &PersistedEnvelope {
101 self
102 }
103 fn retag<T>(&self, decoded: Decoded<T>) -> Decoded<T> {
104 decoded
105 }
106}
107
108impl<P: Copy> sealed::Sealed for (P, StreamKey, PersistedEnvelope) {}
109impl<P: Copy> RawItem for (P, StreamKey, PersistedEnvelope) {
110 type Typed<T> = (P, StreamKey, Decoded<T>);
111 fn envelope(&self) -> &PersistedEnvelope {
112 &self.2
113 }
114 fn retag<T>(&self, decoded: Decoded<T>) -> (P, StreamKey, Decoded<T>) {
115 (self.0, self.1.clone(), decoded)
116 }
117}
118
119/// Adds a typed, codec-reusing view over any stream of raw envelope items.
120///
121/// Covers [`Subscription`](crate::Subscription),
122/// [`read_stream`](crate::RawEventStore::read_stream), and
123/// [`read_all`](crate::RawEventStore::read_all).
124pub trait DecodedStreamExt<I, R>: Stream<Item = Result<I, R>> + Sized
125where
126 I: RawItem,
127{
128 /// Decode each item with `codec`, reusing the codec configured elsewhere.
129 ///
130 /// Owning codecs only — the `for<'a> Output<'a> = E` bound is unsatisfiable
131 /// for a zero-copy codec (whose `Output` borrows the envelope), so the
132 /// compiler steers zero-copy consumers to
133 /// [`for_each_decoded`](Self::for_each_decoded).
134 /// Per-stream items become `Decoded<E>`; `$all` items become
135 /// `(AllPosition, StreamKey, Decoded<E>)` (both tags are preserved beside
136 /// the box).
137 fn decoded<E, C>(
138 self,
139 codec: C,
140 ) -> impl Stream<Item = Result<I::Typed<E>, DecodeStreamError<R, C::Error>>> + Send
141 where
142 C: OwningCodec<E>,
143 E: Send + 'static,
144 I: Send + 'static,
145 R: Send + 'static,
146 Self: Send,
147 {
148 self.map(move |res| {
149 let item = res.map_err(DecodeStreamError::Read)?;
150 let event: E = codec
151 .decode(item.envelope())
152 .map_err(DecodeStreamError::Decode)?;
153 let env = item.envelope();
154 let decoded = Decoded {
155 event,
156 version: env.version(),
157 metadata: env.metadata_bytes(),
158 };
159 Ok(item.retag(decoded))
160 })
161 }
162
163 /// Fold each decoded event by handing your closure the borrowed window —
164 /// works for **owning and zero-copy** codecs, because the window lives only
165 /// for the call and never escapes (internal iteration; no lending stream).
166 /// This is the path a zero-copy codec (rkyv, bytemuck) must take: its
167 /// `Output` borrows the envelope and so cannot be carried away by
168 /// [`decoded`](Self::decoded)'s stream.
169 ///
170 /// `f` receives a [`Decoded<Output<'a>>`] valid only for that call. On a
171 /// never-ending [`Subscription`](crate::Subscription) this runs until the
172 /// first `Err`; over a finite
173 /// [`read_stream`](crate::RawEventStore::read_stream) it runs to completion.
174 ///
175 /// The closure argument is the concrete [`Decoded<Output<'a>>`] — the event
176 /// view plus its per-stream `version` and `metadata`. It is deliberately
177 /// **not** the `I::Typed<_>` shape [`decoded`](Self::decoded) yields: a bare
178 /// closure cannot be inferred higher-ranked over a lifetime hidden behind
179 /// the `I::Typed<_>` associated-type projection (rustc "implementation of
180 /// `FnMut` is not general enough"), so a concrete outer constructor is
181 /// required for the zero-copy path to type-check. Consequently, over an
182 /// `$all` stream neither the `AllPosition` tag nor the [`StreamKey`] is
183 /// surfaced to `f` (the per-stream `Decoded::version` still is) — a
184 /// positioned or routed `$all` consumer must either use
185 /// [`decoded`](Self::decoded) (owning codecs), or fold the raw
186 /// `subscribe_all` stream directly, calling `codec.decode(&env)` per item
187 /// (zero-copy; both tags ride beside the envelope on the raw tuple).
188 fn for_each_decoded<E, C, F, H>(
189 self,
190 codec: C,
191 mut f: F,
192 ) -> impl Future<Output = Result<(), FoldDecodedError<R, C::Error, H>>>
193 where
194 E: ?Sized,
195 C: Decode<E>,
196 F: for<'a> FnMut(Decoded<<C as Decode<E>>::Output<'a>>) -> Result<(), H>,
197 {
198 async move {
199 let stream = self;
200 futures::pin_mut!(stream);
201 while let Some(res) = stream.next().await {
202 let item = res.map_err(FoldDecodedError::Read)?;
203 fold_one(&codec, &mut f, item.envelope())?;
204 }
205 Ok(())
206 }
207 }
208}
209
210/// One decode-then-fold step: decode `env` with `codec`, box it as [`Decoded`],
211/// and hand the (possibly borrowed) window to `f`. Kept a free, synchronous fn
212/// so the higher-ranked `f` call over the borrowed window's lifetime resolves
213/// in a plain fn body (see [`DecodedStreamExt::for_each_decoded`]).
214fn fold_one<R, E, C, F, H>(
215 codec: &C,
216 f: &mut F,
217 env: &PersistedEnvelope,
218) -> Result<(), FoldDecodedError<R, C::Error, H>>
219where
220 E: ?Sized,
221 C: Decode<E>,
222 F: for<'a> FnMut(Decoded<<C as Decode<E>>::Output<'a>>) -> Result<(), H>,
223{
224 let window = codec.decode(env).map_err(FoldDecodedError::Decode)?;
225 let decoded = Decoded {
226 event: window,
227 version: env.version(),
228 metadata: env.metadata_bytes(),
229 };
230 f(decoded).map_err(FoldDecodedError::Handler)
231}
232
233impl<St, I, R> DecodedStreamExt<I, R> for St
234where
235 St: Stream<Item = Result<I, R>>,
236 I: RawItem,
237{
238}
239
240/// Adds phase-aware views over a [`Step`]-tagged stream — what
241/// [`Subscription::subscribe`](crate::Subscription::subscribe) /
242/// [`subscribe_all`](crate::Subscription::subscribe_all) yield.
243///
244/// The `Step` phase marker (the catch-up→live boundary) is intrinsic to a
245/// subscription (a finite read has no such boundary), so it rides on the raw
246/// stream. This trait lets a consumer either **keep** the phase and decode
247/// (`.decoded()`), or **drop** it (`.events()`) and fall back to the plain
248/// [`DecodedStreamExt`] surface a finite read uses.
249///
250/// `Step<I>` is deliberately **not** a [`RawItem`] (the [`CaughtUp`](Step::CaughtUp)
251/// marker carries no envelope), so `.decoded()` here and `.decoded()` on
252/// [`DecodedStreamExt`] are two non-overlapping impls sharing one name.
253pub trait StepStreamExt<I, R>: Stream<Item = Result<Step<I>, R>> + Sized
254where
255 I: RawItem,
256{
257 /// Drop the phase: yield bare `I` items ([`CaughtUp`](Step::CaughtUp)
258 /// removed, [`Event`](Step::Event) unwrapped). The result is a plain raw
259 /// stream, so the full [`DecodedStreamExt`] surface (`.decoded()`,
260 /// `.for_each_decoded()`) applies to it — the path for a consumer that does
261 /// not care whether it is replaying or live.
262 fn events(self) -> impl Stream<Item = Result<I, R>> + Send
263 where
264 Self: Send,
265 I: Send,
266 R: Send,
267 {
268 self.filter_map(|res| async move {
269 match res {
270 Ok(Step::Event(item)) => Some(Ok(item)),
271 Ok(Step::CaughtUp) => None,
272 Err(e) => Some(Err(e)),
273 }
274 })
275 }
276
277 /// Decode each event with `codec`, **preserving** the phase marker: the
278 /// result is a stream of `Step<I::Typed<E>>` — replay events, then exactly
279 /// one [`CaughtUp`](Step::CaughtUp), then live events. Owning codecs only
280 /// (same `for<'a> Output<'a> = E` steer as [`DecodedStreamExt::decoded`]).
281 ///
282 /// This is the projection consumption path: it tells catch-up from live
283 /// *and* hands you typed events, reusing the codec — no magic count, no
284 /// hand-rolled timeout, mnesis-owned error.
285 #[allow(
286 clippy::type_complexity,
287 reason = "the Step<Decoded>/DecodeStreamError item is intrinsic to the contract; an \
288 alias would hide the `impl Stream` the API depends on"
289 )]
290 fn decoded<E, C>(
291 self,
292 codec: C,
293 ) -> impl Stream<Item = Result<Step<I::Typed<E>>, DecodeStreamError<R, C::Error>>> + Send
294 where
295 C: OwningCodec<E>,
296 E: Send + 'static,
297 I: Send + 'static,
298 R: Send + 'static,
299 Self: Send,
300 {
301 self.map(move |res| {
302 let step = res.map_err(DecodeStreamError::Read)?;
303 match step {
304 Step::CaughtUp => Ok(Step::CaughtUp),
305 Step::Event(item) => {
306 let env = item.envelope();
307 let event: E = codec.decode(env).map_err(DecodeStreamError::Decode)?;
308 let decoded = Decoded {
309 event,
310 version: env.version(),
311 metadata: env.metadata_bytes(),
312 };
313 Ok(Step::Event(item.retag(decoded)))
314 }
315 }
316 })
317 }
318}
319
320impl<St, I, R> StepStreamExt<I, R> for St
321where
322 St: Stream<Item = Result<Step<I>, R>>,
323 I: RawItem,
324{
325}