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}