Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 55 additions & 50 deletions crates/nexus-store/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ pub struct NeedsCodec(PhantomData<*const ()>);

impl NeedsCodec {
/// Create a new `NeedsCodec` marker.
#[cfg(not(any(feature = "json")))]
///
/// Always available: [`Store::repository()`] returns a `NeedsCodec` builder
/// in every feature configuration (the `json` feature *adds* a
/// [`.json()`](RepositoryBuilder::json) convenience, it never changes
/// `repository()`'s return type — feature-additivity, issue #211).
pub(crate) const fn new() -> Self {
Self(PhantomData)
}
Expand Down Expand Up @@ -51,9 +55,9 @@ pub struct WithSnapshot<SS, T> {
/// Builder for creating an [`EventStore`] facade
/// from a [`Store`].
///
/// Obtained via [`Store::repository()`]. The builder starts with either a
/// default codec (when the `json` feature is enabled) or [`NeedsCodec`]
/// (requiring an explicit `.codec()` call).
/// Obtained via [`Store::repository()`], which always starts the builder with
/// [`NeedsCodec`]. Set a codec with [`.codec()`](RepositoryBuilder::codec) or,
/// under the `json` feature, [`.json()`](RepositoryBuilder::json).
///
/// Upcasting is not configured here — the resulting facade ships with
/// the no-upcaster [`Repository::load`](crate::Repository::load) /
Expand All @@ -66,14 +70,14 @@ pub struct WithSnapshot<SS, T> {
/// ```ignore
/// let store = Store::new(backend);
///
/// // With the `json` feature (default codec pre-filled):
/// let repo = store.repository().build();
/// // Built-in JSON codec (requires the `json` feature):
/// let repo = store.repository::<Order>().json().build();
///
/// // Custom codec:
/// let repo = store.repository().codec(MyCodec).build();
/// let repo = store.repository::<Order>().codec(MyCodec).build();
///
/// // With upcasting (drop to the facade after build):
/// let repo = store.repository().codec(MyCodec).build();
/// let repo = store.repository::<Order>().codec(MyCodec).build();
/// let root = repo.load_with(id, OrderTransforms::upcast).await?;
/// ```
pub struct RepositoryBuilder<S, C, A, Snap = NoSnapshot> {
Expand Down Expand Up @@ -103,6 +107,20 @@ impl<S, C, A, Snap> RepositoryBuilder<S, C, A, Snap> {
}
}

#[cfg(feature = "json")]
impl<S, C, A, Snap> RepositoryBuilder<S, C, A, Snap> {
/// Use the built-in [`JsonCodec`](crate::JsonCodec) as this facade's codec.
///
/// Convenience for `.codec(JsonCodec::default())`. This is *additive* API:
/// enabling the `json` feature only adds this method — it never changes
/// [`Store::repository()`]'s return type, which is always
/// [`NeedsCodec`] regardless of features (issue #211).
#[must_use]
pub fn json(self) -> RepositoryBuilder<S, crate::JsonCodec, A, Snap> {
self.codec(crate::JsonCodec::default())
}
}

// ═══════════════════════════════════════════════════════════════════════════
// NoSnapshot — plain EventStore
// ═══════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -144,13 +162,18 @@ const DEFAULT_SNAPSHOT_INTERVAL: u64 = 100;
#[cfg(feature = "snapshot")]
const DEFAULT_SCHEMA_VERSION: std::num::NonZeroU32 = std::num::NonZeroU32::MIN;

#[cfg(all(feature = "snapshot-json", feature = "snapshot"))]
#[cfg(feature = "snapshot-json")]
impl<S, C, A> RepositoryBuilder<S, C, A, NoSnapshot> {
/// Configure a snapshot store with JSON codec (default).
/// Configure a snapshot store from a byte-level store, wrapping it in the
/// built-in [`JsonCodec`](crate::JsonCodec).
///
/// Accepts a byte-level [`SnapshotStore<Vec<u8>, Version>`](state::SnapshotStore)
/// and wraps it in [`CodecSnapshotStore`](state::CodecSnapshotStore) with
/// [`JsonCodec`](crate::JsonCodec).
/// [`JsonCodec`](crate::JsonCodec). This is the `snapshot-json` convenience
/// for [`.snapshot_store()`](RepositoryBuilder::snapshot_store) (which takes
/// an already-typed store); enabling `snapshot-json` *adds* this method
/// without altering `snapshot_store()`'s signature — feature-additivity
/// (issue #211).
///
/// Pre-fills:
/// - Trigger: [`EveryNEvents(100)`](state::EveryNEvents)
Expand All @@ -167,7 +190,7 @@ impl<S, C, A> RepositoryBuilder<S, C, A, NoSnapshot> {
clippy::expect_used,
reason = "DEFAULT_SNAPSHOT_INTERVAL is non-zero by inspection"
)]
pub fn snapshot_store<SS>(
pub fn snapshot_store_json<SS>(
self,
snapshot_store: SS,
) -> RepositoryBuilder<
Expand Down Expand Up @@ -195,13 +218,20 @@ impl<S, C, A> RepositoryBuilder<S, C, A, NoSnapshot> {
}
}

#[cfg(all(feature = "snapshot", not(feature = "snapshot-json")))]
#[cfg(feature = "snapshot")]
impl<S, C, A> RepositoryBuilder<S, C, A, NoSnapshot> {
/// Configure a snapshot store.
///
/// Accepts a pre-composed typed [`SnapshotStore<S, Version>`](state::SnapshotStore).
/// If your store is byte-level, compose it with
/// [`CodecSnapshotStore`](state::CodecSnapshotStore) before passing it here.
/// [`CodecSnapshotStore`](state::CodecSnapshotStore) before passing it here —
/// or, under the `snapshot-json` feature, use the
/// [`.snapshot_store_json()`](RepositoryBuilder::snapshot_store_json)
/// convenience which wraps a byte-level store in [`JsonCodec`](crate::JsonCodec).
///
/// This method's signature is the **same** in every feature configuration
/// (issue #211): `snapshot-json` adds `snapshot_store_json()` rather than
/// changing what `snapshot_store()` accepts.
///
/// Pre-fills:
/// - Trigger: [`EveryNEvents(100)`](state::EveryNEvents)
Expand Down Expand Up @@ -304,7 +334,6 @@ where
// Store::repository() entry points
// ═══════════════════════════════════════════════════════════════════════════

#[cfg(feature = "json")]
impl<S: RawEventStore> Store<S> {
/// Start building a repository facade for aggregate `A` over this store.
///
Expand All @@ -314,49 +343,25 @@ impl<S: RawEventStore> Store<S> {
/// per-call annotation. The store itself stays multi-aggregate — mint one
/// facade per aggregate type.
///
/// When the `json` feature is enabled, the builder is pre-filled with
/// [`JsonCodec`](crate::JsonCodec) as the default codec. Override it
/// with [`.codec()`](RepositoryBuilder::codec) if needed.
/// The builder starts with [`NeedsCodec`] in **every** feature
/// configuration — set a codec with [`.codec()`](RepositoryBuilder::codec),
/// or, under the `json` feature, the [`.json()`](RepositoryBuilder::json)
/// convenience, before calling `.build()`. Keeping this return type feature
/// independent is what makes `json` purely *additive* (issue #211): a
/// transitive dependency enabling `json` can never flip this signature out
/// from under code that spelled `NeedsCodec`.
///
/// # Example
///
/// ```ignore
/// let store = Store::new(backend);
///
/// // Use default JSON codec:
/// let orders = store.repository::<Order>().build();
/// let order = orders.load(id).await?; // AggregateRoot<Order> — inferred
///
/// // Override with a custom codec:
/// // Custom codec:
/// let orders = store.repository::<Order>().codec(MyCodec).build();
/// ```
#[must_use]
pub fn repository<A>(&self) -> RepositoryBuilder<S, crate::JsonCodec, A> {
RepositoryBuilder {
store: self.clone(),
codec: crate::JsonCodec::default(),
snapshot: NoSnapshot,
aggregate: PhantomData,
}
}
}

#[cfg(not(any(feature = "json")))]
impl<S: RawEventStore> Store<S> {
/// Start building a repository facade for aggregate `A` over this store.
///
/// Name the aggregate once here (`store.repository::<Order>()`); the
/// resulting facade implements `Repository<A>` for exactly that `A`, so
/// `load`/`save` infer the aggregate with no per-call annotation.
///
/// No default codec is available — call [`.codec()`](RepositoryBuilder::codec)
/// to set one before calling `.build()`.
///
/// # Example
/// let order = orders.load(id).await?; // AggregateRoot<Order> — inferred
///
/// ```ignore
/// let store = Store::new(backend);
/// let orders = store.repository::<Order>().codec(MyCodec).build();
/// // Built-in JSON codec (requires the `json` feature):
/// let orders = store.repository::<Order>().json().build();
/// ```
#[must_use]
pub fn repository<A>(&self) -> RepositoryBuilder<S, NeedsCodec, A> {
Expand Down
2 changes: 1 addition & 1 deletion crates/nexus-store/tests/serde_codec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ mod integration {
#[tokio::test]
async fn json_codec_works_with_event_store() {
let store = Store::new(InMemoryStore::new());
let repo = store.repository().build();
let repo = store.repository().json().build();

// Save a "Created" event
let mut agg = AggregateRoot::<TodoAggregate>::new(TodoId("todo-1".into()));
Expand Down
32 changes: 16 additions & 16 deletions crates/nexus-store/tests/snapshot_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ impl Aggregate for CounterAggregate {
fn repo(trigger: impl PersistTrigger, snapshot_on_read: bool) -> impl Repository<CounterAggregate> {
let raw = InMemoryStore::new();
let store = Store::new(raw);
let inner = store.repository().build();
let inner = store.repository().json().build();

Snapshotting::new(
inner,
Expand Down Expand Up @@ -197,7 +197,7 @@ async fn schema_version_mismatch_falls_back_to_full_replay() {
// Create a repo with schema_version=1, save events+snapshot
let raw = InMemoryStore::new();
let store = Store::new(raw);
let inner = store.repository().build();
let inner = store.repository().json().build();
let snap_store = InMemorySnapshotStore::<CounterState, Version>::new();

let repo_v1 = Snapshotting::new(
Expand All @@ -216,7 +216,7 @@ async fn schema_version_mismatch_falls_back_to_full_replay() {
.unwrap();

// Now create a new repo with schema_version=2 pointing to same stores
let inner2 = store.repository().build();
let inner2 = store.repository().json().build();
let repo_v2 = Snapshotting::new(
inner2,
&snap_store,
Expand Down Expand Up @@ -264,7 +264,7 @@ async fn lazy_snapshot_on_read_after_full_replay() {
let snap_store = InMemorySnapshotStore::<CounterState, Version>::new();

// First, save events without snapshots (no trigger, just on-read)
let inner = store.repository().build();
let inner = store.repository().json().build();
let repo_write = Snapshotting::new(
inner,
&snap_store,
Expand All @@ -289,7 +289,7 @@ async fn lazy_snapshot_on_read_after_full_replay() {

// No snapshot yet (threshold too high).
// Now create a repo with on-read enabled
let inner2 = store.repository().build();
let inner2 = store.repository().json().build();
let repo_read = Snapshotting::new(
inner2,
&snap_store,
Expand Down Expand Up @@ -444,7 +444,7 @@ async fn sequence_snapshot_invalidation_then_new_snapshot() {
let snap_store = InMemorySnapshotStore::<CounterState, Version>::new();

// Save with schema v1, triggers snapshot
let inner = store.repository().build();
let inner = store.repository().json().build();
let repo_v1 = Snapshotting::new(
inner,
&snap_store,
Expand All @@ -460,7 +460,7 @@ async fn sequence_snapshot_invalidation_then_new_snapshot() {
.unwrap();

// Switch to schema v2 — old snapshot ignored, full replay, new snapshot created
let inner2 = store.repository().build();
let inner2 = store.repository().json().build();
let repo_v2 = Snapshotting::new(
inner2,
&snap_store,
Expand Down Expand Up @@ -535,7 +535,7 @@ async fn lifecycle_lazy_snapshot_then_subsequent_load_uses_it() {
let snap_store = InMemorySnapshotStore::<CounterState, Version>::new();

// Save events without snapshot
let inner = store.repository().build();
let inner = store.repository().json().build();
let repo_no_snap = Snapshotting::new(
inner,
&snap_store,
Expand Down Expand Up @@ -570,7 +570,7 @@ async fn lifecycle_lazy_snapshot_then_subsequent_load_uses_it() {
);

// Load with on-read → creates lazy snapshot
let inner2 = store.repository().build();
let inner2 = store.repository().json().build();
let repo_on_read = Snapshotting::new(
inner2,
&snap_store,
Expand Down Expand Up @@ -633,7 +633,7 @@ async fn defensive_snapshot_codec_error_falls_back_to_full_replay() {

// Save events and snapshot with a good codec first
let good_state_store = CodecSnapshotStore::new(&byte_store, nexus_store::JsonCodec::default());
let inner = store.repository().build();
let inner = store.repository().json().build();
let repo_good = Snapshotting::new(
inner,
&good_state_store,
Expand All @@ -650,7 +650,7 @@ async fn defensive_snapshot_codec_error_falls_back_to_full_replay() {

// Now load with the failing codec — should fall back to full replay
let bad_state_store = CodecSnapshotStore::new(&byte_store, FailCodec);
let inner2 = store.repository().build();
let inner2 = store.repository().json().build();
let repo_bad = Snapshotting::new(
inner2,
&bad_state_store,
Expand Down Expand Up @@ -694,7 +694,7 @@ async fn defensive_snapshot_store_load_error_falls_back_to_full_replay() {
let store = Store::new(raw);

// Save some events first
let inner = store.repository().build();
let inner = store.repository().json().build();
let good_repo = Snapshotting::new(
inner,
InMemorySnapshotStore::<CounterState, Version>::new(),
Expand All @@ -713,7 +713,7 @@ async fn defensive_snapshot_store_load_error_falls_back_to_full_replay() {
.unwrap();

// Now load with error store — should fall back to full replay
let inner2 = store.repository().build();
let inner2 = store.repository().json().build();
let bad_repo = Snapshotting::new(
inner2,
ErrorStore,
Expand Down Expand Up @@ -754,7 +754,7 @@ async fn defensive_snapshot_save_failure_does_not_fail_event_save() {

let raw = InMemoryStore::new();
let store = Store::new(raw);
let inner = store.repository().build();
let inner = store.repository().json().build();
let repo = Snapshotting::new(
inner,
SaveErrorStore,
Expand Down Expand Up @@ -802,7 +802,7 @@ async fn isolation_concurrent_loads_from_same_snapshot_get_independent_copies()
let snap_store = InMemorySnapshotStore::<CounterState, Version>::new();

// Save 3 events, snapshot at v3
let inner = store.repository().build();
let inner = store.repository().json().build();
let repo = Snapshotting::new(
inner,
&snap_store,
Expand All @@ -824,7 +824,7 @@ async fn isolation_concurrent_loads_from_same_snapshot_get_independent_copies()
.unwrap();

// Load two copies concurrently
let inner2 = store.repository().build();
let inner2 = store.repository().json().build();
let repo2 = Snapshotting::new(
inner2,
&snap_store,
Expand Down
11 changes: 8 additions & 3 deletions crates/nexus-store/tests/snapshot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ mod builder_tests {
fn builder_without_snapshot_compiles() {
let raw = InMemoryStore::new();
let store = Store::new(raw);
let _repo = store.repository::<Probe>().build();
let _repo = store.repository::<Probe>().json().build();
}

/// Verify the builder API compiles: with snapshot → Snapshotting<EventStore>
Expand All @@ -256,7 +256,11 @@ mod builder_tests {
let raw = InMemoryStore::new();
let store = Store::new(raw);
let snap = InMemorySnapshotStore::<Vec<u8>, Version>::new();
let _repo = store.repository::<Probe>().snapshot_store(snap).build();
let _repo = store
.repository::<Probe>()
.json()
.snapshot_store_json(snap)
.build();
}

/// Verify the builder API compiles: with custom trigger
Expand All @@ -267,7 +271,8 @@ mod builder_tests {
let snap = InMemorySnapshotStore::<Vec<u8>, Version>::new();
let _repo = store
.repository::<Probe>()
.snapshot_store(snap)
.json()
.snapshot_store_json(snap)
.snapshot_trigger(AfterEventTypes::new(&["Done"]))
.snapshot_schema_version(NonZeroU32::new(2).unwrap())
.snapshot_on_read(true)
Expand Down
2 changes: 1 addition & 1 deletion examples/fjall-end-to-end/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ publish = false
bytes.workspace = true
futures.workspace = true
nexus = { path = "../../crates/nexus", features = ["derive"] }
# json → built-in JsonCodec + a default-codec `store.repository()`
# json → built-in JsonCodec + the `.json()` builder convenience
# cbor → the CBOR backup box (implies import + export)
# subscription → the catch-up + live-tail cursor
nexus-store = { path = "../../crates/nexus-store", features = [
Expand Down
Loading
Loading