Skip to main content

mnesis_store/
builder.rs

1use core::marker::PhantomData;
2
3use crate::repository::EventStore;
4use crate::store::{RawEventStore, Store};
5
6// ═══════════════════════════════════════════════════════════════════════════
7// NeedsCodec — compile-time guard
8// ═══════════════════════════════════════════════════════════════════════════
9
10/// Marker type indicating that a [`RepositoryBuilder`] has no codec configured yet.
11///
12/// `NeedsCodec` is `!Send`, which prevents calling `.build()` —
13/// it requires `C: Send + Sync + 'static`.
14/// Set a codec via [`.codec()`](RepositoryBuilder::codec) to unlock
15/// the terminal methods.
16///
17/// You should never need to construct this type directly.
18pub struct NeedsCodec(PhantomData<*const ()>);
19
20impl NeedsCodec {
21    /// Create a new `NeedsCodec` marker.
22    ///
23    /// Always available: [`Store::repository()`] returns a `NeedsCodec` builder
24    /// in every feature configuration (the `json` feature *adds* a
25    /// [`.json()`](RepositoryBuilder::json) convenience, it never changes
26    /// `repository()`'s return type — feature-additivity, issue #211).
27    pub(crate) const fn new() -> Self {
28        Self(PhantomData)
29    }
30}
31
32// ═══════════════════════════════════════════════════════════════════════════
33// Snapshot typestate markers
34// ═══════════════════════════════════════════════════════════════════════════
35
36/// Marker: no snapshot configured (default).
37pub struct NoSnapshot;
38
39/// Snapshot configuration, created by builder methods.
40///
41/// `SS` is a typed snapshot store — the codec (if needed) is composed inside
42/// the store adapter (e.g., [`CodecSnapshotStore`](crate::state::CodecSnapshotStore)).
43#[cfg(feature = "snapshot")]
44pub struct WithSnapshot<SS, T> {
45    store: SS,
46    trigger: T,
47    schema_version: core::num::NonZeroU32,
48    snapshot_on_read: bool,
49}
50
51// ═══════════════════════════════════════════════════════════════════════════
52// RepositoryBuilder
53// ═══════════════════════════════════════════════════════════════════════════
54
55/// Builder for creating an [`EventStore`] facade
56/// from a [`Store`].
57///
58/// Obtained via [`Store::repository()`], which always starts the builder with
59/// [`NeedsCodec`]. Set a codec with [`.codec()`](RepositoryBuilder::codec) or,
60/// under the `json` feature, [`.json()`](RepositoryBuilder::json).
61///
62/// Upcasting is not configured here — the resulting facade ships with
63/// the no-upcaster [`Repository::load`](crate::Repository::load) /
64/// [`save`](crate::Repository::save) path. For schema evolution, use the
65/// facade's inherent [`load_with`](EventStore::load_with) /
66/// [`save_with`](EventStore::save_with) methods after `build()`.
67///
68/// # Example
69///
70/// ```ignore
71/// let store = Store::new(backend);
72///
73/// // Built-in JSON codec (requires the `json` feature):
74/// let repo = store.repository::<Order>().json().build();
75///
76/// // Custom codec:
77/// let repo = store.repository::<Order>().codec(MyCodec).build();
78///
79/// // With metadata provider (e.g. HLC stamp, signature bytes):
80/// let repo = store.repository::<Order>().codec(MyCodec).metadata(|v, e, p| Some(...)).build();
81///
82/// // With upcasting (drop to the facade after build):
83/// let repo = store.repository::<Order>().codec(MyCodec).build();
84/// let root = repo.load_with(id, OrderTransforms::upcast).await?;
85/// ```
86pub struct RepositoryBuilder<S, C, A, Snap = NoSnapshot, M = ()> {
87    store: Store<S>,
88    codec: C,
89    snapshot: Snap,
90    meta: M,
91    /// The aggregate this builder will bind the facade to (named once at
92    /// [`Store::repository::<A>()`]). Threaded through every builder step so
93    /// `.build()` produces an [`EventStore<S, C, A, M>`] that implements
94    /// `Repository<A>` for exactly this `A`.
95    aggregate: PhantomData<fn() -> A>,
96}
97
98impl<S, C, A, Snap, M> RepositoryBuilder<S, C, A, Snap, M> {
99    /// Replace the codec.
100    ///
101    /// Returns a new builder with the updated codec type, preserving
102    /// the store, the bound aggregate, any snapshot configuration, and
103    /// any configured metadata provider.
104    #[must_use]
105    pub fn codec<NewC>(self, codec: NewC) -> RepositoryBuilder<S, NewC, A, Snap, M> {
106        RepositoryBuilder {
107            store: self.store,
108            codec,
109            snapshot: self.snapshot,
110            meta: self.meta,
111            aggregate: PhantomData,
112        }
113    }
114
115    /// Set the metadata provider for this facade.
116    ///
117    /// The provider is called once per event on the write path (post-encode)
118    /// and can return `Some(Metadata)` to attach to the envelope or `None` to
119    /// leave metadata absent. It is infallible; cap errors are handled by the
120    /// provider at [`Metadata`](crate::Metadata) construction.
121    ///
122    /// Order-independent: may be called before or after `.codec()`,
123    /// `.snapshot_store()`, etc.
124    #[must_use]
125    pub fn metadata<NewM>(self, provider: NewM) -> RepositoryBuilder<S, C, A, Snap, NewM> {
126        RepositoryBuilder {
127            store: self.store,
128            codec: self.codec,
129            snapshot: self.snapshot,
130            meta: provider,
131            aggregate: PhantomData,
132        }
133    }
134}
135
136#[cfg(feature = "json")]
137impl<S, C, A, Snap, M> RepositoryBuilder<S, C, A, Snap, M> {
138    /// Use the built-in [`JsonCodec`](crate::JsonCodec) as this facade's codec.
139    ///
140    /// Convenience for `.codec(JsonCodec::default())`. This is *additive* API:
141    /// enabling the `json` feature only adds this method — it never changes
142    /// [`Store::repository()`]'s return type, which is always
143    /// [`NeedsCodec`] regardless of features (issue #211).
144    #[must_use]
145    pub fn json(self) -> RepositoryBuilder<S, crate::JsonCodec, A, Snap, M> {
146        self.codec(crate::JsonCodec::default())
147    }
148}
149
150// ═══════════════════════════════════════════════════════════════════════════
151// NoSnapshot — plain EventStore
152// ═══════════════════════════════════════════════════════════════════════════
153
154impl<S, C, A, M> RepositoryBuilder<S, C, A, NoSnapshot, M>
155where
156    S: RawEventStore,
157    C: Send + Sync + 'static,
158    M: Send + Sync + 'static,
159{
160    /// Build an [`EventStore`] for aggregate `A`.
161    ///
162    /// One terminal for any codec: the owning-vs-borrowing distinction is
163    /// inferred from the codec's [`Decode::Output`](crate::Decode::Output)
164    /// GAT (`E` or `&E`, unified via `Borrow<E>`), not restated at the call
165    /// site. Requires a configured codec (`C: Send + Sync + 'static`, which
166    /// excludes [`NeedsCodec`]).
167    #[must_use]
168    pub fn build(self) -> EventStore<S, C, A, M> {
169        EventStore::new(self.store, self.codec, self.meta)
170    }
171}
172
173// ═══════════════════════════════════════════════════════════════════════════
174// Snapshot builder methods
175// ═══════════════════════════════════════════════════════════════════════════
176
177#[cfg(feature = "snapshot")]
178use super::snapshot::Snapshotting;
179#[cfg(feature = "snapshot")]
180use crate::state;
181#[cfg(feature = "snapshot")]
182use core::num::NonZeroU64;
183
184/// Default snapshot interval.
185#[cfg(feature = "snapshot")]
186const DEFAULT_SNAPSHOT_INTERVAL: u64 = 100;
187
188/// Default snapshot schema version.
189#[cfg(feature = "snapshot")]
190const DEFAULT_SCHEMA_VERSION: core::num::NonZeroU32 = core::num::NonZeroU32::MIN;
191
192#[cfg(feature = "snapshot-json")]
193impl<S, C, A, M> RepositoryBuilder<S, C, A, NoSnapshot, M> {
194    /// Configure a snapshot store from a byte-level store, wrapping it in the
195    /// built-in [`JsonCodec`](crate::JsonCodec).
196    ///
197    /// Accepts a byte-level [`SnapshotStore<Vec<u8>, Version>`](state::SnapshotStore)
198    /// and wraps it in [`CodecSnapshotStore`](state::CodecSnapshotStore) with
199    /// [`JsonCodec`](crate::JsonCodec). This is the `snapshot-json` convenience
200    /// for [`.snapshot_store()`](RepositoryBuilder::snapshot_store) (which takes
201    /// an already-typed store); enabling `snapshot-json` *adds* this method
202    /// without altering `snapshot_store()`'s signature — feature-additivity
203    /// (issue #211).
204    ///
205    /// Pre-fills:
206    /// - Trigger: [`EveryNEvents(100)`](state::EveryNEvents)
207    /// - Schema version: 1
208    /// - Snapshot on read: false
209    ///
210    /// Override any default with `.snapshot_trigger()`, etc.
211    ///
212    /// # Panics
213    ///
214    /// Cannot panic — the internal `expect` is on a compile-time constant.
215    #[must_use]
216    #[allow(
217        clippy::expect_used,
218        reason = "DEFAULT_SNAPSHOT_INTERVAL is non-zero by inspection"
219    )]
220    pub fn snapshot_store_json<SS>(
221        self,
222        snapshot_store: SS,
223    ) -> RepositoryBuilder<
224        S,
225        C,
226        A,
227        WithSnapshot<state::CodecSnapshotStore<SS, crate::JsonCodec>, state::EveryNEvents>,
228        M,
229    > {
230        let typed_store =
231            state::CodecSnapshotStore::new(snapshot_store, crate::JsonCodec::default());
232        RepositoryBuilder {
233            store: self.store,
234            codec: self.codec,
235            snapshot: WithSnapshot {
236                store: typed_store,
237                trigger: state::EveryNEvents(
238                    NonZeroU64::new(DEFAULT_SNAPSHOT_INTERVAL)
239                        .expect("DEFAULT_SNAPSHOT_INTERVAL is non-zero"),
240                ),
241                schema_version: DEFAULT_SCHEMA_VERSION,
242                snapshot_on_read: false,
243            },
244            meta: self.meta,
245            aggregate: PhantomData,
246        }
247    }
248}
249
250#[cfg(feature = "snapshot")]
251impl<S, C, A, M> RepositoryBuilder<S, C, A, NoSnapshot, M> {
252    /// Configure a snapshot store.
253    ///
254    /// Accepts a pre-composed typed [`SnapshotStore<S, Version>`](state::SnapshotStore).
255    /// If your store is byte-level, compose it with
256    /// [`CodecSnapshotStore`](state::CodecSnapshotStore) before passing it here —
257    /// or, under the `snapshot-json` feature, use the
258    /// [`.snapshot_store_json()`](RepositoryBuilder::snapshot_store_json)
259    /// convenience which wraps a byte-level store in [`JsonCodec`](crate::JsonCodec).
260    ///
261    /// This method's signature is the **same** in every feature configuration
262    /// (issue #211): `snapshot-json` adds `snapshot_store_json()` rather than
263    /// changing what `snapshot_store()` accepts.
264    ///
265    /// Pre-fills:
266    /// - Trigger: [`EveryNEvents(100)`](state::EveryNEvents)
267    /// - Schema version: 1
268    /// - Snapshot on read: false
269    ///
270    /// Override any default with `.snapshot_trigger()`, etc.
271    ///
272    /// # Panics
273    ///
274    /// Cannot panic — the internal `expect` is on a compile-time constant.
275    #[must_use]
276    #[allow(
277        clippy::expect_used,
278        reason = "DEFAULT_SNAPSHOT_INTERVAL is non-zero by inspection"
279    )]
280    pub fn snapshot_store<SS>(
281        self,
282        snapshot_store: SS,
283    ) -> RepositoryBuilder<S, C, A, WithSnapshot<SS, state::EveryNEvents>, M> {
284        RepositoryBuilder {
285            store: self.store,
286            codec: self.codec,
287            snapshot: WithSnapshot {
288                store: snapshot_store,
289                trigger: state::EveryNEvents(
290                    NonZeroU64::new(DEFAULT_SNAPSHOT_INTERVAL)
291                        .expect("DEFAULT_SNAPSHOT_INTERVAL is non-zero"),
292                ),
293                schema_version: DEFAULT_SCHEMA_VERSION,
294                snapshot_on_read: false,
295            },
296            meta: self.meta,
297            aggregate: PhantomData,
298        }
299    }
300}
301
302#[cfg(feature = "snapshot")]
303impl<S, C, A, SS, T, M> RepositoryBuilder<S, C, A, WithSnapshot<SS, T>, M> {
304    /// Replace the snapshot trigger.
305    #[must_use]
306    pub fn snapshot_trigger<NewT: state::PersistTrigger>(
307        self,
308        trigger: NewT,
309    ) -> RepositoryBuilder<S, C, A, WithSnapshot<SS, NewT>, M> {
310        RepositoryBuilder {
311            store: self.store,
312            codec: self.codec,
313            snapshot: WithSnapshot {
314                store: self.snapshot.store,
315                trigger,
316                schema_version: self.snapshot.schema_version,
317                snapshot_on_read: self.snapshot.snapshot_on_read,
318            },
319            meta: self.meta,
320            aggregate: PhantomData,
321        }
322    }
323
324    /// Set the schema version for snapshot invalidation.
325    #[must_use]
326    pub const fn snapshot_schema_version(mut self, version: core::num::NonZeroU32) -> Self {
327        self.snapshot.schema_version = version;
328        self
329    }
330
331    /// Enable lazy snapshot creation on read (after full replay).
332    #[must_use]
333    pub const fn snapshot_on_read(mut self, enabled: bool) -> Self {
334        self.snapshot.snapshot_on_read = enabled;
335        self
336    }
337}
338
339// ═══════════════════════════════════════════════════════════════════════════
340// WithSnapshot — Snapshotting<EventStore>
341// ═══════════════════════════════════════════════════════════════════════════
342
343#[cfg(feature = "snapshot")]
344impl<S, C, A, SS, T, M> RepositoryBuilder<S, C, A, WithSnapshot<SS, T>, M>
345where
346    S: RawEventStore,
347    C: Send + Sync + 'static,
348    M: Send + Sync + 'static,
349{
350    /// Build a snapshot-aware [`EventStore`] for aggregate `A` using an owning [`Codec`](crate::Codec).
351    #[must_use]
352    pub fn build(self) -> Snapshotting<EventStore<S, C, A, M>, SS, T> {
353        let inner = EventStore::new(self.store, self.codec, self.meta);
354        let snap = self.snapshot;
355        Snapshotting::new(
356            inner,
357            snap.store,
358            snap.trigger,
359            snap.schema_version,
360            snap.snapshot_on_read,
361        )
362    }
363}
364
365// ═══════════════════════════════════════════════════════════════════════════
366// Store::repository() entry points
367// ═══════════════════════════════════════════════════════════════════════════
368
369impl<S: RawEventStore> Store<S> {
370    /// Start building a repository facade for aggregate `A` over this store.
371    ///
372    /// Name the aggregate once here (`store.repository::<Order>()`); the
373    /// resulting [`EventStore<S, C, A>`] then implements `Repository<A>` for
374    /// exactly that `A`, so `load`/`save` infer the aggregate with no
375    /// per-call annotation. The store itself stays multi-aggregate — mint one
376    /// facade per aggregate type.
377    ///
378    /// The builder starts with [`NeedsCodec`] in **every** feature
379    /// configuration — set a codec with [`.codec()`](RepositoryBuilder::codec),
380    /// or, under the `json` feature, the [`.json()`](RepositoryBuilder::json)
381    /// convenience, before calling `.build()`. Keeping this return type feature
382    /// independent is what makes `json` purely *additive* (issue #211): a
383    /// transitive dependency enabling `json` can never flip this signature out
384    /// from under code that spelled `NeedsCodec`.
385    ///
386    /// # Example
387    ///
388    /// ```ignore
389    /// let store = Store::new(backend);
390    ///
391    /// // Custom codec:
392    /// let orders = store.repository::<Order>().codec(MyCodec).build();
393    /// let order = orders.load(id).await?;        // AggregateRoot<Order> — inferred
394    ///
395    /// // Built-in JSON codec (requires the `json` feature):
396    /// let orders = store.repository::<Order>().json().build();
397    /// ```
398    #[must_use]
399    pub fn repository<A>(&self) -> RepositoryBuilder<S, NeedsCodec, A> {
400        RepositoryBuilder {
401            store: self.clone(),
402            codec: NeedsCodec::new(),
403            snapshot: NoSnapshot,
404            meta: (),
405            aggregate: PhantomData,
406        }
407    }
408}