diff --git a/crates/nexus-store/src/builder.rs b/crates/nexus-store/src/builder.rs index fd3a3b61..274bdcbe 100644 --- a/crates/nexus-store/src/builder.rs +++ b/crates/nexus-store/src/builder.rs @@ -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) } @@ -51,9 +55,9 @@ pub struct WithSnapshot { /// 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) / @@ -66,14 +70,14 @@ pub struct WithSnapshot { /// ```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::().json().build(); /// /// // Custom codec: -/// let repo = store.repository().codec(MyCodec).build(); +/// let repo = store.repository::().codec(MyCodec).build(); /// /// // With upcasting (drop to the facade after build): -/// let repo = store.repository().codec(MyCodec).build(); +/// let repo = store.repository::().codec(MyCodec).build(); /// let root = repo.load_with(id, OrderTransforms::upcast).await?; /// ``` pub struct RepositoryBuilder { @@ -103,6 +107,20 @@ impl RepositoryBuilder { } } +#[cfg(feature = "json")] +impl RepositoryBuilder { + /// 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 { + self.codec(crate::JsonCodec::default()) + } +} + // ═══════════════════════════════════════════════════════════════════════════ // NoSnapshot — plain EventStore // ═══════════════════════════════════════════════════════════════════════════ @@ -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 RepositoryBuilder { - /// 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, 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) @@ -167,7 +190,7 @@ impl RepositoryBuilder { clippy::expect_used, reason = "DEFAULT_SNAPSHOT_INTERVAL is non-zero by inspection" )] - pub fn snapshot_store( + pub fn snapshot_store_json( self, snapshot_store: SS, ) -> RepositoryBuilder< @@ -195,13 +218,20 @@ impl RepositoryBuilder { } } -#[cfg(all(feature = "snapshot", not(feature = "snapshot-json")))] +#[cfg(feature = "snapshot")] impl RepositoryBuilder { /// Configure a snapshot store. /// /// Accepts a pre-composed typed [`SnapshotStore`](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) @@ -304,7 +334,6 @@ where // Store::repository() entry points // ═══════════════════════════════════════════════════════════════════════════ -#[cfg(feature = "json")] impl Store { /// Start building a repository facade for aggregate `A` over this store. /// @@ -314,49 +343,25 @@ impl Store { /// 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::().build(); - /// let order = orders.load(id).await?; // AggregateRoot — inferred - /// - /// // Override with a custom codec: + /// // Custom codec: /// let orders = store.repository::().codec(MyCodec).build(); - /// ``` - #[must_use] - pub fn repository(&self) -> RepositoryBuilder { - RepositoryBuilder { - store: self.clone(), - codec: crate::JsonCodec::default(), - snapshot: NoSnapshot, - aggregate: PhantomData, - } - } -} - -#[cfg(not(any(feature = "json")))] -impl Store { - /// Start building a repository facade for aggregate `A` over this store. - /// - /// Name the aggregate once here (`store.repository::()`); the - /// resulting facade implements `Repository` 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 — inferred /// - /// ```ignore - /// let store = Store::new(backend); - /// let orders = store.repository::().codec(MyCodec).build(); + /// // Built-in JSON codec (requires the `json` feature): + /// let orders = store.repository::().json().build(); /// ``` #[must_use] pub fn repository(&self) -> RepositoryBuilder { diff --git a/crates/nexus-store/tests/serde_codec_tests.rs b/crates/nexus-store/tests/serde_codec_tests.rs index 986775e6..80787922 100644 --- a/crates/nexus-store/tests/serde_codec_tests.rs +++ b/crates/nexus-store/tests/serde_codec_tests.rs @@ -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::::new(TodoId("todo-1".into())); diff --git a/crates/nexus-store/tests/snapshot_integration_tests.rs b/crates/nexus-store/tests/snapshot_integration_tests.rs index 49dc08fa..494b9d9a 100644 --- a/crates/nexus-store/tests/snapshot_integration_tests.rs +++ b/crates/nexus-store/tests/snapshot_integration_tests.rs @@ -100,7 +100,7 @@ impl Aggregate for CounterAggregate { fn repo(trigger: impl PersistTrigger, snapshot_on_read: bool) -> impl Repository { let raw = InMemoryStore::new(); let store = Store::new(raw); - let inner = store.repository().build(); + let inner = store.repository().json().build(); Snapshotting::new( inner, @@ -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::::new(); let repo_v1 = Snapshotting::new( @@ -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, @@ -264,7 +264,7 @@ async fn lazy_snapshot_on_read_after_full_replay() { let snap_store = InMemorySnapshotStore::::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, @@ -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, @@ -444,7 +444,7 @@ async fn sequence_snapshot_invalidation_then_new_snapshot() { let snap_store = InMemorySnapshotStore::::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, @@ -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, @@ -535,7 +535,7 @@ async fn lifecycle_lazy_snapshot_then_subsequent_load_uses_it() { let snap_store = InMemorySnapshotStore::::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, @@ -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, @@ -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, @@ -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, @@ -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::::new(), @@ -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, @@ -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, @@ -802,7 +802,7 @@ async fn isolation_concurrent_loads_from_same_snapshot_get_independent_copies() let snap_store = InMemorySnapshotStore::::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, @@ -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, diff --git a/crates/nexus-store/tests/snapshot_tests.rs b/crates/nexus-store/tests/snapshot_tests.rs index a2d3588b..a357e2ee 100644 --- a/crates/nexus-store/tests/snapshot_tests.rs +++ b/crates/nexus-store/tests/snapshot_tests.rs @@ -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::().build(); + let _repo = store.repository::().json().build(); } /// Verify the builder API compiles: with snapshot → Snapshotting @@ -256,7 +256,11 @@ mod builder_tests { let raw = InMemoryStore::new(); let store = Store::new(raw); let snap = InMemorySnapshotStore::, Version>::new(); - let _repo = store.repository::().snapshot_store(snap).build(); + let _repo = store + .repository::() + .json() + .snapshot_store_json(snap) + .build(); } /// Verify the builder API compiles: with custom trigger @@ -267,7 +271,8 @@ mod builder_tests { let snap = InMemorySnapshotStore::, Version>::new(); let _repo = store .repository::() - .snapshot_store(snap) + .json() + .snapshot_store_json(snap) .snapshot_trigger(AfterEventTypes::new(&["Done"])) .snapshot_schema_version(NonZeroU32::new(2).unwrap()) .snapshot_on_read(true) diff --git a/examples/fjall-end-to-end/Cargo.toml b/examples/fjall-end-to-end/Cargo.toml index 842b1463..ccb87ff9 100644 --- a/examples/fjall-end-to-end/Cargo.toml +++ b/examples/fjall-end-to-end/Cargo.toml @@ -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 = [ diff --git a/examples/fjall-end-to-end/src/lib.rs b/examples/fjall-end-to-end/src/lib.rs index d231b77a..e48027a5 100644 --- a/examples/fjall-end-to-end/src/lib.rs +++ b/examples/fjall-end-to-end/src/lib.rs @@ -143,7 +143,7 @@ pub async fn run_persistence(path: &Path) -> Result<(AccountState, AccountState) // flushing the keyspace before we reopen the same path. let before = { let store = FjallStore::builder(path).open()?.into_store(); - let repo = store.repository::().build(); + let repo = store.repository::().json().build(); seed_account(&repo, &id, "Alice", &[1_000, 500]).await?; // One more command so the persisted stream isn't trivial. `repo` is bound // to `BankAccount` (named at `repository::()`), so `load` @@ -155,7 +155,7 @@ pub async fn run_persistence(path: &Path) -> Result<(AccountState, AccountState) // Reopen from scratch and rehydrate purely from the on-disk event log. let store = FjallStore::builder(path).open()?.into_store(); - let repo = store.repository::().build(); + let repo = store.repository::().json().build(); let reopened = repo.load(id).await?; let after = reopened.state().clone(); @@ -187,7 +187,7 @@ pub struct SubscriptionOutcome { /// demonstrate strict-after resume. pub async fn run_subscription(path: &Path) -> Result { let store = FjallStore::builder(path).open()?.into_store(); - let repo = store.repository::().build(); + let repo = store.repository::().json().build(); let id = AccountId("alice".to_owned()); // Seed exactly three events (open + 2 deposits → v1, v2, v3). @@ -224,7 +224,7 @@ pub async fn run_subscription(path: &Path) -> Result().build(); + let repo = writer_store.repository::().json().build(); writer_barrier.wait().await; let mut account = repo.load(writer_id).await.expect("load for live append"); repo.execute(&mut account, Deposit { amount: 250 }) @@ -347,7 +347,7 @@ pub async fn run_export_import(work_dir: &Path) -> Result().build(); + let src_repo = src.repository::().json().build(); let specs: [(&str, &str, &[u64]); 3] = [ ("alice", "Alice", &[1_000, 500]), ("bob", "Bob", &[200]), @@ -377,7 +377,7 @@ pub async fn run_export_import(work_dir: &Path) -> Result().build(); + let dst_repo = dst.repository::().json().build(); let mut restored = Vec::new(); for summary in &originals { let restored_root = dst_repo.load(AccountId(summary.id.clone())).await?; @@ -456,7 +456,7 @@ pub async fn run_produce_and_sync(path: &Path) -> Result().build(); + let repo = store.repository::().json().build(); seed_account(&repo, &id, "Device", &[10, 20, 30]).await?; let rehydrated_balance = repo.load(id).await?.state().balance;