mnesis_store/store.rs
1use alloc::sync::Arc;
2
3use mnesis::{ErrorId, Version};
4
5use crate::envelope::{PendingBatch, PersistedEnvelope};
6use crate::error::{AppendError, AppendValidationError};
7use crate::stream::EventStream;
8use crate::stream_id::StreamKey;
9
10// ═══════════════════════════════════════════════════════════════════════════
11// Store<S> — Arc-wrapped handle to a RawEventStore backend
12// ═══════════════════════════════════════════════════════════════════════════
13
14/// Shared handle to a [`RawEventStore`] backend.
15///
16/// `Store` wraps the backend in an `Arc`, making it cheap to clone and
17/// safe to share across tasks. It carries no codec, upcaster, or
18/// aggregate binding — it is just a database handle.
19///
20/// Use [`repository()`](Store::repository) to obtain a
21/// [`RepositoryBuilder`](crate::builder::RepositoryBuilder), then
22/// configure a codec and upcaster before calling `.build()`.
23///
24/// # Example
25///
26/// ```ignore
27/// // Open flows left-to-right; `.into_store()` is the de-nested `Store::new`.
28/// let store = FjallStore::builder("path").open()?.into_store();
29///
30/// // One per-aggregate facade per aggregate; the store is the shared substrate.
31/// let orders = store.repository::<Order>().codec(OrderCodec).build();
32/// let users = store.repository::<User>().codec(UserCodec).build();
33/// ```
34#[derive(Debug)]
35pub struct Store<S> {
36 inner: Arc<S>,
37}
38
39impl<S> Store<S> {
40 /// Wrap a raw event store backend in a shared handle.
41 pub fn new(raw: S) -> Self {
42 Self {
43 inner: Arc::new(raw),
44 }
45 }
46
47 /// Borrow the underlying raw store.
48 ///
49 /// The escape hatch for users who need the substrate directly — when
50 /// the [`Repository`](crate::Repository) facade's `load` / `save` isn't
51 /// flexible enough (e.g. you want to filter, peek, branch, or chain
52 /// custom combinators during load). Hand the borrowed `&S` to
53 /// [`RawEventStore::read_stream`] / [`RawEventStore::append`] and
54 /// compose your own chain via [`futures::StreamExt`] /
55 /// [`futures::TryStreamExt`].
56 ///
57 /// Users who just want "load this aggregate" should stay on the facade.
58 ///
59 /// # Example
60 ///
61 /// Substrate-path read: convert the adapter error eagerly and drive
62 /// a custom fold.
63 ///
64 /// ```ignore
65 /// use futures::TryStreamExt;
66 /// use mnesis_store::{RawEventStore, Store, StreamKey};
67 ///
68 /// async fn count_events<S: RawEventStore>(
69 /// store: &Store<S>,
70 /// id: &StreamKey,
71 /// from: mnesis::Version,
72 /// ) -> Result<usize, MyError> {
73 /// let stream = store.raw().read_stream(id, from).await.map_err(MyError::Adapter)?;
74 /// stream.map_err(MyError::Adapter).try_fold(0usize, |acc, _| async move { Ok(acc + 1) }).await
75 /// }
76 /// ```
77 ///
78 /// [`RawEventStore`]: crate::RawEventStore
79 /// [`RawEventStore::read_stream`]: crate::RawEventStore::read_stream
80 /// [`RawEventStore::append`]: crate::RawEventStore::append
81 #[must_use]
82 pub fn raw(&self) -> &S {
83 &self.inner
84 }
85
86 /// Borrow the inner `Arc<S>` for the subscription module's use.
87 ///
88 /// `pub(crate)` so the subscription module can pull the `Arc` out for
89 /// [`Subscription::new`](crate::subscription::Subscription::new) without
90 /// leaking `Arc` to library users. Only the `subscription` feature needs
91 /// it, so it is gated to avoid a dead-code warning otherwise.
92 #[cfg(feature = "subscription")]
93 #[must_use]
94 pub(crate) const fn arc(&self) -> &Arc<S> {
95 &self.inner
96 }
97}
98
99impl<S> Clone for Store<S> {
100 fn clone(&self) -> Self {
101 Self {
102 inner: Arc::clone(&self.inner),
103 }
104 }
105}
106
107// ═══════════════════════════════════════════════════════════════════════════
108// RawEventStore<M> — byte-level append + read_stream trait
109// ═══════════════════════════════════════════════════════════════════════════
110
111/// What database adapters implement. Bytes in, bytes out.
112///
113/// Knows nothing about typed events or codecs. The `EventStore` facade
114/// calls this trait after encoding events into `PendingEnvelope`.
115pub trait RawEventStore: Send + Sync {
116 /// The error type for store operations.
117 type Error: core::error::Error + Send + Sync + 'static;
118
119 /// The stream type for reading events.
120 ///
121 /// Owned, non-GAT, `'static` — a `futures::Stream` of
122 /// `Result<PersistedEnvelope, Self::Error>`. The owned-`Bytes`
123 /// envelope means cursors don't need to lend per-record; the
124 /// stream's `Item` is the envelope by value.
125 ///
126 /// Note: the subscription path ([`Subscription::subscribe`]) requires the
127 /// stream be `Unpin`. No bound is imposed here, but all shipped adapters
128 /// (`ScanCursor`, `InMemoryStream`) satisfy it.
129 ///
130 /// [`Subscription::subscribe`]: crate::subscription::Subscription::subscribe
131 type Stream: EventStream<Error = Self::Error> + 'static;
132
133 /// The adapter-defined `$all` resume position. See [`AllPosition`].
134 ///
135 /// A scalar for an embedded store (fjall's `GlobalSeq`), a commit-ordered
136 /// composite for a concurrent SQL store (postgres's `(txid, seq)`). It rides
137 /// *alongside* `$all` events on [`AllStream`](Self::AllStream); the
138 /// (position-free) [`PersistedEnvelope`] never carries it.
139 type AllPosition: AllPosition;
140
141 /// The stream type for an all-streams (`$all`) read.
142 ///
143 /// Owned, non-GAT, `'static` — a `futures::Stream` of
144 /// `Result<(Self::AllPosition, StreamKey, PersistedEnvelope), Self::Error>`,
145 /// ascending by [`AllPosition`], not by `(stream, version)`. Each item
146 /// carries three parts: the **position** for checkpointing (resume needs no
147 /// global field on the envelope), the **stream key for routing** (the store
148 /// knows the origin stream at append time, so an `$all` consumer routes on
149 /// raw id bytes without decoding the payload), and the **envelope** for
150 /// content. Distinct from [`Stream`](Self::Stream) because the global order
151 /// is a different physical index.
152 ///
153 /// Note: the subscription path ([`Subscription::subscribe`]) requires the
154 /// stream be `Unpin`. No bound is imposed here, but all shipped adapters
155 /// (`ScanCursor`, `InMemoryStream`) satisfy it.
156 ///
157 /// [`Subscription::subscribe`]: crate::subscription::Subscription::subscribe
158 type AllStream: futures::Stream<
159 Item = Result<(Self::AllPosition, StreamKey, PersistedEnvelope), Self::Error>,
160 > + Send
161 + 'static;
162
163 /// Append events to a stream with optimistic concurrency.
164 ///
165 /// `expected_version` is the version the aggregate was at before
166 /// new events were applied. The adapter checks this against the
167 /// current stream version and rejects if they don't match.
168 ///
169 /// # Atomicity
170 ///
171 /// The version check and event insertion **must** be atomic. If they
172 /// are separate operations (e.g. SELECT then INSERT), a concurrent
173 /// writer can slip in between, corrupting the stream. Use
174 /// transactions, CAS operations, or a lock to prevent this.
175 ///
176 /// # Implementor contract
177 ///
178 /// Envelopes **must** have strictly sequential versions starting from
179 /// `expected_version + 1`. Implementations **must** reject batches
180 /// where versions are out of order, have gaps, or contain duplicates.
181 /// Accepting malformed batches corrupts the event stream.
182 ///
183 /// # `$all` position
184 ///
185 /// Each appended event is assigned an adapter-defined
186 /// [`AllPosition`](Self::AllPosition) — the order an `$all` subscription
187 /// resumes from. It is **not** carried on the [`PersistedEnvelope`]; on the
188 /// read path it is surfaced as a tag on each
189 /// [`AllStream`](Self::AllStream) item. The position **must** be
190 /// monotonically increasing across *all* streams in commit order but is
191 /// **not** required to be gapless — an adapter may skip values (e.g. after
192 /// an aborted append), and readers must tolerate gaps.
193 ///
194 /// # Return value
195 ///
196 /// On success this returns the position assigned to the run's **last**
197 /// event — the same position `$all` reports for that event. It is the
198 /// read-your-writes token (#330): a caller awaits its `$all` consumer's
199 /// checkpoint reaching this value and then knows its own write is visible
200 /// there. The last event's position is the whole run's barrier because
201 /// `$all` delivery is position-ordered, so a consumer that has reached it
202 /// has necessarily been delivered every earlier event of the same append.
203 ///
204 /// Implementors **must** return the position actually assigned, not a
205 /// counter read back afterwards — the value has to be the one the
206 /// committing transaction stamped, or a concurrent append can make the two
207 /// disagree.
208 ///
209 /// # Non-empty input
210 ///
211 /// [`PendingBatch`] carries at least one envelope, so there is no
212 /// write-nothing case and the returned position is unconditional. An
213 /// adapter therefore never has to answer "what position did zero events
214 /// land at". Note this removes the former version-assertion probe
215 /// (`append(id, expected, &[])`, which checked `expected_version` and wrote
216 /// nothing); it had no production caller and returns additively as its own
217 /// method if it is ever wanted.
218 fn append(
219 &self,
220 id: &StreamKey,
221 expected_version: Option<Version>,
222 envelopes: PendingBatch<'_>,
223 ) -> impl core::future::Future<Output = Result<Self::AllPosition, AppendError<Self::Error>>> + Send;
224
225 /// Open a stream of events.
226 ///
227 /// Events are yielded one at a time as a `futures::Stream` of
228 /// owned [`PersistedEnvelope`](crate::envelope::PersistedEnvelope)s.
229 ///
230 /// `from` is **inclusive**: the stream yields every event with
231 /// `version >= from`, in ascending `Version` order, then terminates with
232 /// `None`. This matches [`read_all`](Self::read_all)'s `from` semantics;
233 /// the catchup seam relies on this inclusivity to resume without skipping
234 /// the boundary event.
235 ///
236 /// # Batching
237 ///
238 /// An adapter **may** chunk or paginate internally (e.g. materialize a
239 /// fixed number of rows at a time and keyset-resume on the stream version
240 /// as the cursor drains) but is **not** required to — bounding resident
241 /// memory is the adapter's concern. Whatever it does is invisible to
242 /// callers: `next()` yields events in ascending `Version` order from `from`
243 /// (inclusive) and returns `None` once the persisted stream is exhausted,
244 /// regardless of how the events are chunked. Memory is bounded by the
245 /// adapter's implementation — fjall, for instance, uses a single lazy LSM
246 /// cursor rather than fixed-size batches.
247 fn read_stream(
248 &self,
249 id: &StreamKey,
250 from: Version,
251 ) -> impl core::future::Future<Output = Result<Self::Stream, Self::Error>> + Send;
252
253 /// Open a one-shot read over **all** streams, ordered by
254 /// [`AllPosition`](Self::AllPosition).
255 ///
256 /// `from` is **exclusive**: the stream yields every event *strictly after*
257 /// `from` (`None` = from the very beginning), in ascending
258 /// [`AllPosition`](Self::AllPosition) order, each item **tagged** with its
259 /// position, then terminates with `None`. Resume is `Ord`-based with no
260 /// successor function — the live loop reopens with the last-delivered
261 /// position and the adapter reads "strictly greater". The position sequence
262 /// is monotonic but **not** gapless; this read tolerates gaps by scanning a
263 /// range rather than stepping a successor.
264 ///
265 /// The exclusive `from` here is an **intentional** asymmetry with
266 /// [`read_stream`](Self::read_stream)'s **inclusive** `Version` `from`
267 /// (CLAUDE rule 4): a single stream has a gapless successor sequence, but a
268 /// concurrent adapter's composite `$all` position has none.
269 ///
270 /// This is the building block under an all-streams subscription; the
271 /// never-ending wait-when-caught-up behaviour is layered on top.
272 ///
273 /// # Stream attribution
274 ///
275 /// Each item carries the [`StreamKey`] of the stream the event was appended
276 /// to — a **store guarantee**, not a payload convention. The per-stream
277 /// read ([`read_stream`](Self::read_stream)) deliberately does NOT stamp
278 /// it: there the id is the query argument and every returned envelope
279 /// belongs to it by construction (intentional read-path asymmetry).
280 ///
281 /// # Batching
282 ///
283 /// Like [`read_stream`](Self::read_stream), an adapter **may** chunk or
284 /// paginate internally (keyset-resume on the position) but is **not**
285 /// required to. The externally-observable contract is unchanged: events are
286 /// yielded in ascending position order strictly after `from`, the stream
287 /// terminates with `None` when caught up, and resident memory is bounded by
288 /// the adapter's implementation.
289 fn read_all(
290 &self,
291 from: Option<Self::AllPosition>,
292 ) -> impl core::future::Future<Output = Result<Self::AllStream, Self::Error>> + Send;
293
294 /// Wrap this backend in a shared [`Store`] handle.
295 ///
296 /// The de-nested alternative to [`Store::new(self)`](Store::new): opening a
297 /// store reads left-to-right —
298 /// `FjallStore::builder(path).open()?.into_store()` — instead of the
299 /// inside-out `Store::new(FjallStore::builder(path).open()?)`. Exactly
300 /// equivalent to `Store::new`; every adapter gets it for free as a provided
301 /// method, and the raw backend stays reachable via [`Store::raw`].
302 #[must_use]
303 fn into_store(self) -> Store<Self>
304 where
305 Self: Sized,
306 {
307 Store::new(self)
308 }
309}
310
311// ═══════════════════════════════════════════════════════════════════════════
312// Store<S> as a delegating RawEventStore — the front door (issue #247)
313// ═══════════════════════════════════════════════════════════════════════════
314
315/// `Store<S>` is itself a [`RawEventStore`], forwarding every method to its
316/// inner backend.
317///
318/// This makes the handle the front door: `store.append(..)` / `read_stream` /
319/// `read_all` work directly, and — because [`EventExporter`] and
320/// [`EventImporter`] are blanket-impl'd for every `RawEventStore` (and
321/// `RawEventStore + AtomicAppend`) — `store.export_stream(..)` /
322/// `store.import(..)` come for free once `Store<S>` also forwards
323/// [`StreamLister`] / [`AtomicAppend`] (in the `export` / `import` modules). So
324/// a `Store<S>` holder never needs `.raw()` to back up or restore, and a
325/// `Store<S>` is substitutable wherever a `RawEventStore`-bounded value is
326/// expected. `.raw()` remains the escape hatch for reaching the concrete `&S`.
327///
328/// [`EventExporter`]: crate::export::EventExporter
329/// [`EventImporter`]: crate::import::EventImporter
330/// [`StreamLister`]: crate::export::StreamLister
331/// [`AtomicAppend`]: crate::import::AtomicAppend
332impl<S: RawEventStore> RawEventStore for Store<S> {
333 type Error = S::Error;
334 type Stream = S::Stream;
335 type AllPosition = S::AllPosition;
336 type AllStream = S::AllStream;
337
338 async fn append(
339 &self,
340 id: &StreamKey,
341 expected_version: Option<Version>,
342 envelopes: PendingBatch<'_>,
343 ) -> Result<Self::AllPosition, AppendError<Self::Error>> {
344 self.raw().append(id, expected_version, envelopes).await
345 }
346
347 async fn read_stream(
348 &self,
349 id: &StreamKey,
350 from: Version,
351 ) -> Result<Self::Stream, Self::Error> {
352 self.raw().read_stream(id, from).await
353 }
354
355 async fn read_all(
356 &self,
357 from: Option<Self::AllPosition>,
358 ) -> Result<Self::AllStream, Self::Error> {
359 self.raw().read_all(from).await
360 }
361}
362
363// ═══════════════════════════════════════════════════════════════════════════
364// AllPosition — adapter-defined `$all` resume position
365// ═══════════════════════════════════════════════════════════════════════════
366
367/// Where an `$all` subscription resumes — **adapter-defined**.
368///
369/// A scalar for an embedded store (fjall's `GlobalSeq`), a commit-ordered
370/// composite for a concurrent SQL store (postgres's `(txid, seq)`), an LSN for
371/// a WAL tail. `mnesis-store` owns only this trait — the *abstraction*; the
372/// concrete position lives in the adapter (dependency direction: the store
373/// cannot reference its adapters), and it is **never** carried on the
374/// position-free [`PersistedEnvelope`].
375///
376/// # Carried alongside events, not derived from them
377///
378/// The position rides on each [`AllStream`](RawEventStore::AllStream) item as a
379/// tag `(AllPosition, StreamKey, PersistedEnvelope)`. A consumer checkpoints
380/// the position it last saw and hands it back to
381/// [`read_all`](RawEventStore::read_all) /
382/// `subscribe_all` to resume — so the consumer's checkpoint type is
383/// adapter-defined and must be serializable (fjall: a `u64`; postgres: a pair).
384///
385/// # `Ord`, no successor
386///
387/// The live loop resumes **strictly after** the last delivered position using
388/// [`Ord`] alone. There is deliberately no `next`/successor: a composite
389/// position such as `(txid, seq)` has no natural `+1` in `txid` space, and the
390/// `$all` read is **exclusive** (`WHERE pos > from`), so `Ord` is all the loop
391/// needs.
392///
393/// # Not a distributed clock
394///
395/// An `AllPosition` orders one store's appends; it is **not** a cross-producer
396/// or causal timestamp. A distributed adapter does not widen or reinterpret it:
397/// causal/HLC metadata rides in the event's `metadata` bytes, the store never
398/// orders by it, and merging across producers is the consumer's job.
399pub trait AllPosition: Copy + Ord + Send + Sync + core::fmt::Debug + 'static {}
400
401/// Validate the append contract once, for every adapter.
402///
403/// `current` is the stream's current max version (`0` = a fresh stream that has
404/// never been appended to). This enforces the two invariants the
405/// [`RawEventStore::append`] doc specifies in prose but leaves unimplemented:
406///
407/// 1. **Optimistic concurrency** — `expected` must equal `current` (a fresh
408/// stream requires `expected == None`; a non-empty stream requires
409/// `expected == Some(current)`). Any mismatch is an
410/// [`AppendValidationError::Conflict`].
411/// 2. **Strict-sequentiality** — `envelopes` must be `current+1, current+2, …`,
412/// overflow-checked. A gap, out-of-order entry, or `u64::MAX` overflow is a
413/// `Conflict` (gap/out-of-order) or [`AppendValidationError::VersionOverflow`]
414/// (overflow — never a retry-eligible `Conflict`, rule 3).
415///
416/// Adapters call this *inside* their transaction/lock, **before** any staging or
417/// wire encoding, then map the neutral [`AppendValidationError`] into their own
418/// `AppendError`. Centralising it here means the contract has one source of truth
419/// instead of being copy-pasted (and silently drifted) across every adapter.
420///
421/// # Errors
422///
423/// - [`AppendValidationError::Conflict`] — `expected` does not match `current`
424/// (optimistic-concurrency mismatch), or an envelope's version is not the
425/// strict successor of its predecessor (a gap or out-of-order entry).
426/// - [`AppendValidationError::VersionOverflow`] — the version sequence would
427/// advance past `u64::MAX` (never reported as a retry-eligible `Conflict`).
428pub fn validate_append_versions(
429 current: u64,
430 expected: Option<Version>,
431 envelopes: PendingBatch<'_>,
432 id: &StreamKey,
433) -> Result<(), AppendValidationError> {
434 // 1. Optimistic concurrency. `actual` is `None` for a fresh stream so that
435 // `expected == None` is the only valid expectation there.
436 let actual: Option<Version> = if current == 0 {
437 None
438 } else {
439 Version::new(current)
440 };
441 if expected != actual {
442 return Err(AppendValidationError::Conflict {
443 stream_id: ErrorId::from_display(id),
444 expected,
445 actual,
446 });
447 }
448
449 // 2. Strict-sequentiality. A running `checked_add` counter — no index→u64
450 // cast, overflow-safe near `u64::MAX` (rule 2).
451 let mut next = current;
452 for env in envelopes {
453 next = next
454 .checked_add(1)
455 .ok_or(AppendValidationError::VersionOverflow)?;
456 if env.version().as_u64() != next {
457 return Err(AppendValidationError::Conflict {
458 stream_id: ErrorId::from_display(id),
459 expected: Version::new(next),
460 actual: Some(env.version()),
461 });
462 }
463 }
464 Ok(())
465}
466
467#[cfg(test)]
468#[allow(clippy::unwrap_used, reason = "test code")]
469#[allow(clippy::panic, reason = "test code")]
470mod validate_append_tests {
471 use super::*;
472 use crate::envelope::{PendingEnvelope, pending_envelope};
473
474 fn sk() -> StreamKey {
475 StreamKey::from_slice(b"s")
476 }
477
478 fn batch(envs: &[PendingEnvelope]) -> PendingBatch<'_> {
479 PendingBatch::new(envs).expect("test batches are non-empty by construction")
480 }
481
482 fn env(version: u64) -> PendingEnvelope {
483 pending_envelope(Version::new(version).unwrap())
484 .event_type("E")
485 .payload(b"p".as_slice())
486 .build()
487 .unwrap()
488 }
489
490 #[test]
491 fn fresh_stream_ok() {
492 assert!(validate_append_versions(0, None, batch(&[env(1), env(2), env(3)]), &sk()).is_ok());
493 }
494
495 #[test]
496 fn existing_stream_ok() {
497 assert!(
498 validate_append_versions(5, Version::new(5), batch(&[env(6), env(7)]), &sk()).is_ok()
499 );
500 }
501
502 #[test]
503 fn stale_expected_conflict() {
504 let err =
505 validate_append_versions(5, Version::new(4), batch(&[env(6)]), &sk()).unwrap_err();
506 assert!(matches!(err, AppendValidationError::Conflict { .. }));
507 }
508
509 #[test]
510 fn fresh_stream_with_expected_some_conflict() {
511 // A non-empty expectation on a brand-new stream is a conflict.
512 let err =
513 validate_append_versions(0, Version::new(1), batch(&[env(1)]), &sk()).unwrap_err();
514 assert!(matches!(err, AppendValidationError::Conflict { .. }));
515 }
516
517 #[test]
518 fn gapped_batch_conflict() {
519 let err = validate_append_versions(0, None, batch(&[env(1), env(3)]), &sk()).unwrap_err();
520 match err {
521 AppendValidationError::Conflict {
522 expected, actual, ..
523 } => {
524 assert_eq!(expected, Version::new(2));
525 assert_eq!(actual, Some(env(3).version()));
526 }
527 AppendValidationError::VersionOverflow => {
528 panic!("expected Conflict, got VersionOverflow")
529 }
530 }
531 }
532
533 #[test]
534 fn out_of_order_batch_conflict() {
535 let err = validate_append_versions(0, None, batch(&[env(2), env(1)]), &sk()).unwrap_err();
536 match err {
537 AppendValidationError::Conflict {
538 expected, actual, ..
539 } => {
540 assert_eq!(expected, Version::new(1));
541 assert_eq!(actual, Some(env(2).version()));
542 }
543 AppendValidationError::VersionOverflow => {
544 panic!("expected Conflict, got VersionOverflow")
545 }
546 }
547 }
548
549 #[test]
550 fn wrong_start_version_conflict() {
551 let err =
552 validate_append_versions(5, Version::new(5), batch(&[env(7)]), &sk()).unwrap_err();
553 assert!(matches!(err, AppendValidationError::Conflict { .. }));
554 }
555
556 #[test]
557 fn version_overflow_is_version_overflow_not_conflict() {
558 let err =
559 validate_append_versions(u64::MAX, Version::new(u64::MAX), batch(&[env(1)]), &sk())
560 .unwrap_err();
561 assert!(matches!(err, AppendValidationError::VersionOverflow));
562 }
563}