From 1d59cc750939387c2d35732ba075697f842d815a Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Thu, 9 Jul 2026 22:48:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(store)!:=20extract=20nexus-wake=20+=20?= =?UTF-8?q?nexus-inmemory=20=E2=80=94=20tokio=20out=20of=20nexus-store=20(?= =?UTF-8?q?#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nexus-store's production code was no_std+alloc-clean except tokio, confined to notify.rs behind the subscription feature — a packaging choice, not a design dependency (#224 already cut the WakeSource seam). Two new crates: - nexus-wake: StreamNotifiers/WakeReg/NotifyError moved verbatim from nexus_store::notify (public path now nexus_wake::*), owning tokio, foldhash, parking_lot. The StreamNotifiers white-box tests moved along from wake.rs. - nexus-inmemory: InMemoryStore/InMemoryStream/InMemoryAllPos (from nexus_store::testing) + InMemorySnapshotStore (from state.rs) as a first-class adapter crate; export/import features forward to nexus-store's. StoredFrame now carries its SchemaVersion typed (captured at append like offsets), so the read path never re-parses header bytes and wire's SCHEMA_VERSION_OFFSET stays pub(crate); the unreachable CorruptSchemaVersion variant is gone. nexus-store drops tokio/foldhash/parking_lot entirely; the subscription feature is a dep-free gate; the testing feature is deleted. Deviation from the issue text: the issue kept InMemoryStore in nexus-store with a dev-dep on nexus-wake, but InMemoryStore embeds StreamNotifiers structurally behind a *feature*, and features cannot enable dev-deps — that design is a normal- dep cycle cargo rejects. Adapter-crate extraction resolves it. Test relocation (cargo constraint: a dev-dep cycle unifies types only for integration tests; the lib-test build recompiles the crate under cfg(test), so nexus-inmemory's impls satisfy none of its bounds): - InMemoryStore-fixture test mods of export/import/cbor/decoded/projection moved to tests/ (import's private plan_section tests stayed inline as plan_tests; cbor's white-box mod stayed, only the store-using pipeline test moved). - inmemory_store_tests + inmemory_conformance moved to nexus-inmemory. - catchup/subscription_cursor white-box mods (pub(crate) seams) now drive an in-crate TestStore double (src/test_support.rs, cfg(test)-only); the public subscribe surface stays proven against the real adapter + real nexus-wake in tests/subscription_tests.rs. fjall/postgres repoint nexus_store::notify -> nexus_wake; examples repoint nexus_store::testing -> nexus_inmemory. CLAUDE.md architecture docs updated (also folds in the shared-rules dedup to the global ~/.claude/CLAUDE.md). Closes #300 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 112 +- Cargo.lock | 36 +- Cargo.toml | 2 + crates/nexus-fjall/Cargo.toml | 1 + crates/nexus-fjall/src/builder.rs | 2 +- crates/nexus-fjall/src/error.rs | 4 +- crates/nexus-fjall/src/store.rs | 2 +- crates/nexus-inmemory/Cargo.toml | 34 + .../testing.rs => nexus-inmemory/src/lib.rs} | 127 +- crates/nexus-inmemory/src/snapshot.rs | 65 + .../tests/inmemory_conformance.rs | 2 +- .../tests/inmemory_store_tests.rs | 3 +- crates/nexus-postgres/Cargo.toml | 1 + crates/nexus-postgres/src/store.rs | 2 +- crates/nexus-postgres/src/wake.rs | 2 +- crates/nexus-store/Cargo.toml | 23 +- crates/nexus-store/src/catchup.rs | 18 +- crates/nexus-store/src/cbor.rs | 76 -- crates/nexus-store/src/decoded.rs | 105 -- crates/nexus-store/src/export.rs | 429 ------- crates/nexus-store/src/import.rs | 1057 +--------------- crates/nexus-store/src/lib.rs | 13 +- crates/nexus-store/src/projection.rs | 368 ------ crates/nexus-store/src/state.rs | 76 -- crates/nexus-store/src/subscription_cursor.rs | 22 +- crates/nexus-store/src/test_support.rs | 226 ++++ crates/nexus-store/src/wake.rs | 100 +- .../tests/adversarial_property_tests.rs | 4 +- .../tests/borrowing_codec_tests.rs | 2 +- .../tests/bounded_batch_proptest.rs | 2 +- crates/nexus-store/tests/bug_hunt_tests.rs | 4 +- .../nexus-store/tests/cbor_pipeline_tests.rs | 92 ++ .../nexus-store/tests/decoded_inline_tests.rs | 112 ++ .../nexus-store/tests/decoded_view_tests.rs | 4 +- crates/nexus-store/tests/event_store_tests.rs | 2 +- crates/nexus-store/tests/execute_tests.rs | 2 +- .../nexus-store/tests/export_inline_tests.rs | 438 +++++++ .../nexus-store/tests/import_inline_tests.rs | 1114 +++++++++++++++++ crates/nexus-store/tests/integration_tests.rs | 2 +- .../tests/phase_subscription_tests.rs | 4 +- .../tests/projection_stepper_tests.rs | 373 ++++++ crates/nexus-store/tests/projection_tests.rs | 3 +- crates/nexus-store/tests/property_tests.rs | 2 +- .../nexus-store/tests/repository_qa_tests.rs | 2 +- .../tests/saga_repository_tests.rs | 2 +- crates/nexus-store/tests/security_tests.rs | 4 +- crates/nexus-store/tests/serde_codec_tests.rs | 3 +- .../tests/snapshot_integration_tests.rs | 8 +- crates/nexus-store/tests/snapshot_tests.rs | 9 +- .../tests/step_stream_ext_tests.rs | 4 +- .../nexus-store/tests/subscription_tests.rs | 4 +- .../nexus-store/tests/wire_alignment_tests.rs | 3 +- crates/nexus-wake/Cargo.toml | 26 + .../src/notify.rs => nexus-wake/src/lib.rs} | 95 +- examples/projection-tokio/Cargo.toml | 3 +- examples/projection-tokio/tests/loop_tests.rs | 7 +- examples/store-and-kernel/Cargo.toml | 3 +- examples/store-and-kernel/src/main.rs | 2 +- examples/store-inmemory/Cargo.toml | 3 +- examples/store-inmemory/src/main.rs | 2 +- 60 files changed, 2791 insertions(+), 2457 deletions(-) create mode 100644 crates/nexus-inmemory/Cargo.toml rename crates/{nexus-store/src/testing.rs => nexus-inmemory/src/lib.rs} (92%) create mode 100644 crates/nexus-inmemory/src/snapshot.rs rename crates/{nexus-store => nexus-inmemory}/tests/inmemory_conformance.rs (98%) rename crates/{nexus-store => nexus-inmemory}/tests/inmemory_store_tests.rs (98%) create mode 100644 crates/nexus-store/src/test_support.rs create mode 100644 crates/nexus-store/tests/cbor_pipeline_tests.rs create mode 100644 crates/nexus-store/tests/decoded_inline_tests.rs create mode 100644 crates/nexus-store/tests/export_inline_tests.rs create mode 100644 crates/nexus-store/tests/import_inline_tests.rs create mode 100644 crates/nexus-store/tests/projection_stepper_tests.rs create mode 100644 crates/nexus-wake/Cargo.toml rename crates/{nexus-store/src/notify.rs => nexus-wake/src/lib.rs} (90%) diff --git a/CLAUDE.md b/CLAUDE.md index 8e6a57bf..bcd52a12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,11 +26,24 @@ cargo fmt --all ### Crate Dependency Graph ``` -nexus-store --> nexus (kernel) -nexus-fjall --> nexus-store -nexus-macros <-- nexus (kernel, optional via "derive" feature) +nexus-store --> nexus (kernel) +nexus-wake --> nexus-store (in-process wake registry; owns tokio/foldhash/parking_lot) +nexus-inmemory --> nexus-store, nexus-wake (in-memory adapter; nexus-store's test fixture via dev-dep cycle) +nexus-fjall --> nexus-store, nexus-wake +nexus-postgres --> nexus-store, nexus-wake +nexus-macros <-- nexus (kernel, optional via "derive" feature) ``` +`nexus-store` itself has **no** tokio/foldhash/parking_lot dependency (#300): the +subscription loop is generic over the `WakeSource` trait, and the tokio-backed +in-process impl (`StreamNotifiers`) lives in `nexus-wake`. `nexus-store`'s own +tests reach `InMemoryStore` through a **path-only dev-dependency on +`nexus-inmemory`** (a legal dev-dep cycle) — which unifies types **only for +integration tests in `tests/`**; the lib-test target recompiles the crate under +`cfg(test)` as a distinct crate, so inline white-box test mods that need a store +(`catchup.rs`, `subscription_cursor.rs`) use the in-crate `test_support.rs` +double instead. + ### Kernel Crate (`nexus`) — Flat Module Layout - **`aggregate.rs`** — `Aggregate` trait (binds State + Error + Id), implemented on a **bare marker** type (a unit struct, never instantiated — state lives in `AggregateRoot`). `AggregateRoot` (read-only state container with version tracking) exposes two public driver methods: `replay(version, &event)` (rehydration in strict version order, also the supported path for manual / no-store event sourcing) and `commit_persisted(version, &events)` (the **single** post-persist sync — advances the version *and* folds the events into state atomically, so the version-ahead-of-state desync is unrepresentable by construction; #212). The lower-level `advance_version`/`apply_events`/`apply_event` are now **private in-crate helpers** (`commit_persisted` and the `testing` fixtures call them; `pub(crate)` for `apply_events`, fully private for the other two), no longer `#[doc(hidden)] pub` — complying with rule 4 ("`#[doc(hidden)]` is not access control") before the 1.0 freeze. `AggregateRoot` also carries the inherent `handle(&self, cmd)` **dispatch** that forwards to `A::handle(self.state(), cmd)`, so a loaded `AggregateRoot` is directly decidable. `AggregateState` trait (`initial()`, `apply(self, &Event) -> Self`; requires `Clone` for panic safety). `Handle` trait (per-command **decide** function, `handle(state: &State, cmd) -> Events`) — a pure function of `(state, command)`, implemented **on the marker**, with no access to version/identity (a decision never branches on persistence position); supertrait is `Aggregate`. There is **no entity newtype** and **no `AggregateEntity`/`from_root`** — `Handle` on the local marker coheres even for foreign command types, and the seam between "load returns `AggregateRoot`" and "decide" is dissolved by the dispatch method. Configurable limit: `MAX_REHYDRATION_EVENTS` (default 1M, `NonZeroUsize`). One `Handle` per command type (two impls differing only in `N` for the same `C` are ambiguous at the dispatch call site). @@ -69,13 +82,13 @@ Flat layout — one file per concept, no module subdirectories. The earlier `cod - `RawEventStore` trait: byte-level `append`, `read_stream`, and `read_all`, plus the provided `into_store(self) -> Store` (#244 — de-nests `Store::new`). `append` stamps each event with the next `GlobalSeq`. `read_stream` returns the adapter's concrete owned `Stream` associated type (a bounded per-stream scan from a `Version`); `read_all` returns the `AllStream` associated type (a bounded `$all` scan from a `GlobalSeq`). Both `from` bounds are **inclusive** — strict-after resume is the subscription loop's concern (`next_pos`), not the store's. - `GlobalSeq`: store-local global sequence number stamped on every event at append time — the `Version` analogue across *all* of one producer's streams, and the position an all-streams subscription resumes from. **Monotonic but not gapless** — an aborted append may burn values, so consumers must tolerate gaps. - **`batch.rs`** — `BatchSize` / `BatchSizeError`: a validated read/refill batch size carrying the `1..=MAX_BATCH` invariant by construction (`MAX_BATCH = 4096` is the compile-time ceiling on resident rows, `DEFAULT_BATCH = 256`), so an out-of-range value is unrepresentable past `BatchSize::new` and a builder that accepts a `BatchSize` cannot be handed an invalid one. Read paths never materialize more than `batch_size` event rows at once — the bound ties a config knob to a memory invariant. `BatchSizeError` is a `thiserror` struct carrying the rejected value and the ceiling. -- **Subscription machinery (`subscription.rs` / `wake.rs` / `catchup.rs` / `subscription_cursor.rs` / `notify.rs`)** — feature-gated under `subscription`. The catch-up-then-live-tail loop lives **here in nexus-store as ONE generic, no-`Box` machine**, reusable by *any* adapter that implements `RawEventStore + WakeSource` — the postgres-readiness win (the loop was extracted from `nexus-fjall`, which had grown a bespoke per-adapter copy). +- **Subscription machinery (`subscription.rs` / `wake.rs` / `catchup.rs` / `subscription_cursor.rs`)** — feature-gated under `subscription` (a **dep-free** gate since #300). The catch-up-then-live-tail loop lives **here in nexus-store as ONE generic, no-`Box` machine**, reusable by *any* adapter that implements `RawEventStore + WakeSource` — the postgres-readiness win (the loop was extracted from `nexus-fjall`, which had grown a bespoke per-adapter copy). The in-process wake impl (`StreamNotifiers`) lives in the separate `nexus-wake` crate. - `subscription.rs` — `Subscription`, the user-facing **generic builder**. Built via `Subscription::new(&store)` from a `Store` handle (one `Arc` clone). `subscribe(&id, from)` / `subscribe_all(from)` are **synchronous and fallible**: they return `Result, ::Error>> + Send, ::Error>` (per-stream; `$all` yields `Step<(AllPosition, PersistedEnvelope)>`) — register failure surfaces eagerly as the `Err`, read failures stream in-band as `Err` items (two distinct error domains, rule 3). **The item is `Step`-tagged because the catch-up→live boundary is what makes a subscription a subscription** (#250): a finite read has no such boundary, so phase rides on `subscribe` itself rather than a separate `subscribe_typed`/`cursor` method — phase and decode are the two orthogonal axes, composed by combinators (see `decoded.rs`'s `StepStreamExt`), never welded into one method name. The cursor **never returns `None`** (parks when caught up). `from: None` = beginning; `from: Some(v)` = events *strictly after* `v` (strict-after resume; no duplicate on reopen). The stream is `!Unpin` (it is the `unfold` of the live loop), so consumers `pin!` it — the zero-cost (no-`Box`) tradeoff. There is **no adapter-facing subscription trait** — the old `RawSubscription` / `RawAllSubscription` + `sealed` module were deleted; an adapter need only implement `RawEventStore` + `WakeSource`. - `step.rs` — `Step` = `Event(T) | CaughtUp` (#250), the frozen two-variant phase marker every subscription item carries. `CaughtUp` is emitted **exactly once** at the backlog→live boundary the `live_stepped` loop already detects; everything before it is replay, everything after is live. Exhaustive (non-error enum, no `#[non_exhaustive]`); no `FellBehind` (the loop doesn't distinguish a lagging live consumer — a consumer watches positions). `Step::map` transforms the payload and passes `CaughtUp` through, so the marker flows unbroken through `subscribe`'s tag-drop and `.decoded()`'s decode. - `wake.rs` — adapter-pluggable wake, used only as a generic bound (never `dyn`). `WakeSource: Send + Sync + 'static` with `register(&self, stream: Option<&[u8]>) -> Result` (`None` = `$all`) and `wake(&self, stream)` (called *after* a durable commit). `WakeRegistration` with `arm(&self) -> impl Future + Send + 'static` — an RPITIT future (no associated `Wait` type, no boxing); the returned future is `'static` (carries no borrow of `self`) and lost-wakeup-safe (captures a "seen version" at `arm` time). In-process adapters use `StreamNotifiers`; distributed adapters (postgres) implement these over `LISTEN`/`NOTIFY`. - `catchup.rs` — `pub(crate)` `Catchup` seam fusing one subscription target's bounded position-keyed scan with a wait. Methods: `read_from(from)` (open a bounded scan from `from` INCLUSIVE), `arm()`, `position_of(env)`, and `next_pos(Option) -> Option` (the **single** resume transition: `None` → first position, `Some(v)` → strictly after `v` via `v.next()`, overflow → `None`). Two compile-time impls — `StreamCatchup` (per-stream, `Position = Version`) and `AllCatchup` (`$all`, `Position = GlobalSeq`) — let the one live loop monomorphize into two branch-free machines. `OwnedSubId` satisfies `Id`'s `'static` bound across reopened scans. - `subscription_cursor.rs` — the **single copy** of the arm-before-confirm-rescan lost-wakeup discipline: one generic `live(c, from) -> impl Stream` loop (RPIT, no `Box`). Reopens its bounded scan every `CATCHUP_CHUNK` (1024) delivered rows so one adapter scan (and any GC watermark it pins) is never held across an unbounded backlog. On error it surfaces `Err` in-band and reopens from the last *successfully delivered* position on the next poll — no internal retry/dead-letter; recovery is the consumer's. A `C::Scan: Unpin` bound is kept method-local (not on the trait) because `StreamExt::next` needs it. - - `notify.rs` — `StreamNotifiers`, the *in-process* per-stream wake registry, now impls `WakeSource` (`Registration = WakeReg`). Carries a `tokio::sync::watch` **generation** counter per stream (and one for `$all`) alongside its legacy `Notify` — `arm` clones a `watch::Receiver`, calls `mark_unchanged()` to pin the seen-version to the exact `arm` instant, then awaits `changed()` (only a *future* bump resolves it, closing the lost-wakeup window). Built via `Arc::new_cyclic` so it holds a `Weak` self-ref: the `register(&self)` trait method must build a drop-guard `SubscriptionGuard` that owns an `Arc` without an `&Arc` receiver. The inherent `wake` bumps the per-stream generation+`Notify` AND the `$all` watch generation (so a `WakeReg` armed on `$all` wakes), but *not* the legacy `$all` `Notify` (that path stays driven by `wake_all`, preserving the old test). Both the `watch`-generation and legacy `Notify`/`wake_all` paths live during the transition. Drop-guard lifecycle (entry exists iff ≥1 live subscriber, reaped synchronously at zero) is unchanged. + - **`nexus-wake` crate** (formerly `notify.rs` here, moved by #300 — public path `nexus_store::notify::*` → `nexus_wake::*`) — `StreamNotifiers`, the *in-process* per-stream wake registry, impls `WakeSource` (`Registration = WakeReg`). Carries a `tokio::sync::watch` **generation** counter per stream (and one for `$all`) alongside its legacy `Notify` — `arm` clones a `watch::Receiver`, calls `mark_unchanged()` to pin the seen-version to the exact `arm` instant, then awaits `changed()` (only a *future* bump resolves it, closing the lost-wakeup window). Built via `Arc::new_cyclic` so it holds a `Weak` self-ref: the `register(&self)` trait method must build a drop-guard `SubscriptionGuard` that owns an `Arc` without an `&Arc` receiver. The inherent `wake` bumps the per-stream generation+`Notify` AND the `$all` watch generation (so a `WakeReg` armed on `$all` wakes), but *not* the legacy `$all` `Notify` (that path stays driven by `wake_all`, preserving the old test). Both the `watch`-generation and legacy `Notify`/`wake_all` paths live during the transition. Drop-guard lifecycle (entry exists iff ≥1 live subscriber, reaped synchronously at zero) is unchanged. - **`stream.rs`** — `EventStream` marker trait over `futures::Stream> + Send`. No methods of its own. The associated `Error` type is recovered from the stream's `Item` so call sites can bound on `S: EventStream` instead of carrying an extra generic. All combinators come from `futures::StreamExt` / `TryStreamExt`. Replaced the GAT-lending `EventStream` + `BaseEventStream` + `EventStreamExt` + `OwnedEventStream` + `IntoStream` + `Map` + `TryMap` + `MapErr` + `TryScan` (~1370 lines deleted) — the owned `Bytes` envelope removed the lifetime cliff that motivated the GAT. - **`decoded.rs`** — Consumer-side typed view over the raw subscription / `read_stream` / `read_all` streams (#249). Keeps the deliberate raw multi-consumer contract; adds an ergonomic layer that **reuses the configured codec** instead of a hand-rolled `from_slice`. `Decoded` is one box (event + `version` + `metadata`) generic over the payload — owned `E` on the stream path, the borrowed window `Decode::Output<'a>` inside a fold. `DecodedStreamExt` adds two methods over any `Stream>` where `I: RawItem` (sealed; impl'd for `PersistedEnvelope` → `Decoded` and `(AllPosition, PersistedEnvelope)` → `(AllPosition, Decoded)`, tag preserved): `.decoded(codec)` — **owning codecs only** (`for<'a> Output<'a> = E`, so a zero-copy codec cannot satisfy it and the compiler steers to the fold) → a stream of carry-away `Decoded`; and `.for_each_decoded(codec, f)` — **owning and zero-copy** (rkyv/bytemuck) → internal-iteration fold handing the borrowed window to a closure, so no lending stream is reintroduced. `for_each_decoded` surfaces the per-stream `version` but not the `$all` position tag (positioned `$all` folds use `.decoded()` or a raw `subscribe_all` loop with `codec.decode`). Error domains stay distinct (rule 3): `DecodeStreamError` (`Read`/`Decode`), `FoldDecodedError` (`Read`/`Decode`/`Handler`). Not subscription-gated — also sugars finite reads. **`StepStreamExt`** (#250) adds the phase-aware surface over a `Step`-tagged stream (what `subscribe`/`subscribe_all` yield): `.events()` drops the phase (removes `CaughtUp`, unwraps `Event`) → a bare raw stream the full `DecodedStreamExt` then applies to (the events-only path; also the public form of the old internal `live` filter); `.decoded(codec)` **keeps** the phase, decoding each `Event` → `Step>` (the projection consumption path — tells catch-up from live *and* hands you typed events, owning codecs only, same steer as `DecodedStreamExt::decoded`). `Step` is deliberately **not** a `RawItem` (the `CaughtUp` marker carries no envelope), so the two `.decoded()` methods are non-overlapping impls sharing one name. Composition table: `subscribe` (raw+phase) · `.events()` (raw, no phase) · `.events().decoded()` (typed, no phase) · `.decoded()` (typed+phase) · `.events().for_each_decoded()` (zero-copy fold, no phase). - **`wire.rs`** — One canonical frame builder used by every adapter: `wire::encode_frame(global_seq, schema_version, &event_type, &payload, metadata) -> Result` and `wire::decode_frame(&[u8]) -> Result`. Layout: `[u8 frame_format_version][u64 LE global_seq][u32 LE schema_version][u16 LE event_type_len][u32 LE meta_len][event_type][metadata?][padding][payload]` where `meta_len == u32::MAX` is the absent-metadata sentinel. The **leading `frame_format_version` byte (offset 0) is read first**: `decode_frame` decodes it via the typed `FrameFormatVersion` enum and branches on layout (`match header.format_version { V1 => decode_frame_v1(..) }`), rejecting an unknown byte as a typed `DecodeError::UnsupportedFrameVersion` rather than misparsing. It is the on-disk escape hatch that makes the frame **evolvable** (a future v2 can change alignment / add a CRC / store the payload length without a full data migration) — added in the pre-1.0-freeze window because the frame is irreversible once real data exists (#205); `HEADER_FIXED_SIZE` is `19` (version byte + the four fixed fields). The padding makes the payload pointer land on a **16-byte boundary** inside the returned `Bytes` — a wire-format invariant zero-copy decoders (rkyv default alignment, flatbuffers, `#[repr(C)]` POD up to 16) rely on; the version byte shifts the fixed fields one byte later but `align_padding` recomputes the payload position so the 16-byte boundary holds. Backing buffer built via `aligned_vec::AVec>` → `bytes::Bytes::from_owner(avec)` for zero-copy ownership transfer that preserves alignment. @@ -83,7 +96,7 @@ Flat layout — one file per concept, no module subdirectories. The earlier `cod - `SnapshotStore` trait: atomic persistence of derived state plus the position it was folded to. `hydrate(id, schema_version) -> Hydrated` and `commit(id, schema_version, position, &state)` — state and position are saved and loaded *together*, never separately, so a half-write is unrepresentable. Generic over the position type `P` (`Version` for a single stream, `GlobalSeq` for a multi-stream projection). **`hydrate` returns a three-state `Hydrated` (`Absent` | `Stale { stored_schema }` | `Found { position, state }`), not `Option`** — the extra state keeps "nothing saved" distinct from "saved but under a different schema version". That distinction is invisible to an aggregate snapshot (both mean "replay the stream", and the decorator collapses them via `Hydrated::into_found`), but load-bearing for a projection: `Stale` means a schema bump invalidated the saved state and the next step is a **full re-fold of the whole `$all` stream** — on a mobile/IoT host a long, battery-heavy operation the host must see coming. `Stale` deliberately carries no stale bytes (derived state has no upcasting path — a schema change forces a rebuild, never a migration). Aggregate/projection adapters validate the stored schema is `NonZeroU32` (a zero is corruption, not staleness) before branching `Stale` vs `Found`. - `CodecSnapshotStore`: bridges a byte-level `SnapshotStore, P>` (what fjall implements) to a typed `SnapshotStore` via `Decode` / `Encode`. The position `P` passes through untouched. `CodecSnapshotStoreError::Wire` covers the rare wire-build failure when synthesizing an envelope from snapshot bytes. - `PersistTrigger` trait: `EveryNEvents(N)` (bucket-crossing), `AfterEventTypes(&[&str])` (semantic). Used by both projection runners and the snapshot decorator. - - Inline `#[cfg(feature = "testing")] mod testing` block: `InMemorySnapshotStore`. + - `InMemorySnapshotStore` moved to the `nexus-inmemory` crate (#300). - **`projection.rs`** — Feature-gated under `projection`. Slim by design. - `Projector` trait: pure fallible fold function (`initial()` + `apply(state, &event) -> Result`). Fallibility is intentional: projections may do checked arithmetic where aggregates do not. Recovery policy (skip/fail/dead-letter) is the framework's concern, not the projector's. nexus ships no runner; the IO-driven loop is the consumer's. See `examples/projection-tokio`. - `Projection` (#255) — an inert per-event **stepper** that *assembles* the four projection primitives (`Projector` + `PersistTrigger` + `SnapshotStore`, plus a `Subscription` outside) with no runner. `load` (five named inputs, no codec, no `for<'a>`) hydrates and returns `(stepper, starting_state)`; `advance(state, Decoded) -> state` folds one event and commits `(state, position)` if the trigger fires; `flush(&state)` persists the folded-but-unpersisted tail on shutdown. On a `Hydrated::Stale` hydrate `load` starts from `initial()` **and** records the discarded schema version, surfaced via `rebuilding_from() -> Option` — so a host distinguishes a costly schema-bump rebuild (`Some(old)`) from an ordinary fresh start (`None`, disambiguated from a resume via `checkpoint()`). Deliberately **no second `Start` enum** duplicating `Hydrated`'s trichotomy: `load`'s signature stays `(stepper, state)` and the one genuinely-new bit rides on a single accessor. Owns no loop — the host (tokio `while let`, Agency/Bombay actor mailbox) drives it. **State flows through the caller, not the stepper**, because `Projector::apply` consumes by value: keeping it inside would force `Clone` (hot-path) or an `Option`/poison edge (rule 4). The codec is absent by construction — decode with `.decoded()` before `advance`, so the assembly never names a codec nor restates the owning-codec bound. `ProjectionError::{Apply, Commit}` keeps the fold and persist domains distinct (rule 3). Prior art: Akka Projection (inert value, host spawns). The old 8-arg/6-generic hand-wired `run_projection` (which leaked the HRTB) is gone; `examples/projection-tokio` now builds its loop on the stepper. @@ -99,9 +112,9 @@ Flat layout — one file per concept, no module subdirectories. The earlier `cod - **`import.rs`** — The write side of backup/restore (#145). `EventImporter::import(sections, route, atomicity)` places box-decoded `StreamSection`s (each `ImportBlock` is an `Event(PersistedEnvelope)` or a checksum-failed `Corrupt`) onto caller-routed target streams — events carry no per-event id, so the `route` closure maps each section's once-recorded origin id to a target. Semantics: **picky per stream** (a stream's first incoming version must equal its next-expected, never silently trimmed), **halt-not-skip** (a bad block stops *its* stream at the last good version, never punches a gap), idempotency a side-effect of the store's sequential version check. `Atomicity` is a caller policy: `WholeChunk` (all-or-nothing, server bulk-restore) vs `PerStream` (a bad block stops only its stream — mobile resilience). Results come back in a per-stream `ImportReport` of `StreamReport`/`StreamOutcome` (`Complete`/`Corrupt`/`Mismatch`, with "where the stream sits" living *inside* each variant so a `Complete` always carries a real `Version` and illegal states are unrepresentable); `ImportError` (`Malformed`/`Aborted`/`Store`/`VersionOverflow`) is reserved for whole-operation failures. `WholeChunk` needs the **`AtomicAppend` capability trait** — `atomic_append_many(&[PlannedAppend])` committing several per-stream runs in **one** transaction (the primitive `RawEventStore::append`, per-stream only, cannot give it; adapters back it with fjall cross-partition `write_tx` / postgres `BEGIN..COMMIT` / the in-memory mutex), with `AtomicAppendError` distinguishing a cross-stream `Conflict { index, actual }` from a `Store` failure (rule 3). `FjallStore` implements `AtomicAppend` + `StreamLister` behind its `import`/`export` features (#220), so the full export → CBOR box → file → import pipeline is proven on the persistent adapter, not just `InMemoryStore`. - **`cbor.rs`** — The default CBOR backup box: the bytes↔sections travel codec sitting between `export.rs`'s raw `PersistedEnvelope` stream and `import.rs`'s `EventImporter` (the box is pluggable — a CESR box is a future alternative). A chunk is a CBOR sequence (RFC 8742): a header map (`ChunkHeader` — magic `nxch`, `format_version`, optional `origin` producer/device id), then per-stream section headings (recording the origin stream id **once**) each followed by block arrays `[crc32c, body]`; `decode_chunk` checks each block's per-block crc32c *before* trusting the body and yields `StreamSection`s, mapping a checksum failure to a non-error `ImportBlock::Corrupt`. The **write path** is the typestate `ChunkWriter` (#246, generic over a `minicbor::encode::Write` sink — `Vec` baseline, file-backed via minicbor's `Writer` a future extension): the header is emitted in `ChunkWriter::new` (constructor-is-the-header, so it can't be forgotten or misordered — Arrow `StreamWriter` discipline), `section(id) -> SectionWriter<'_, W>` opens a section, and `SectionWriter::block`/`try_extend` (the latter drains an export `Stream`) are the **only** way to write blocks — so "block before heading" is *un-nameable*, a compile error. There is **no `finish`** (a CBOR sequence has no terminator, RFC 8742); `into_sink()` recovers the buffer. Driven from export streams via `TryStreamExt::try_fold`, so backing up a store is one functional pipeline with no manual byte concatenation; the old low-level `encode_header`/`encode_section_heading`/`encode_block` were deleted. Read stays the asymmetric `decode_header`/`decode_chunk` — justified because decode is handed the whole buffer while encode is driven from a stream. **A separate format from `wire.rs`** — the backup box preserves each event's `version` and `schema_version` explicitly (export does no rewrite) but **deliberately omits `global_seq`**, which is store-local and re-stamped import-side on re-append, so the box never carries it as a load-bearing field. Error domains are kept distinct (rule 3): `ChunkError` is `Malformed`-only (the decode domain), the write path surfaces `WriteError` (sink/encode) and `SectionError` (`try_extend`'s distinct read-vs-write arms), and all are distinct from `ImportError`. - **`error.rs`** — `StoreError` (generic over adapter/codec/upcaster errors, allocation-free), `AppendError`, `UpcastError` (generic over transform error), `InvalidSchemaVersion`. All use `ArrayString<64>` for stream IDs instead of heap-allocated strings. -- **`testing.rs`** — `InMemoryStore`, `InMemoryStream`, `InMemoryStoreError` (feature-gated behind `testing`). +- **`nexus-inmemory` crate** (formerly `testing.rs` here behind the deleted `testing` feature, moved by #300) — `InMemoryStore`, `InMemoryStream`, `InMemoryStoreError`, `InMemorySnapshotStore`: the full in-memory **adapter** (RawEventStore + WakeSource + SnapshotStore + StreamLister/AtomicAppend behind its `export`/`import` features). `StoredFrame` carries its `SchemaVersion` as a typed field captured at append (like `offsets`), so the read path never re-parses header bytes and the frame byte layout stays private to `nexus_store::wire`. -Feature flags: `serde`, `json` (implies `serde`), `snapshot`, `snapshot-json`, `projection`, `projection-json`, `subscription` (the generic catch-up + live-tail loop; pulls `tokio` + `foldhash` + `parking_lot`), `testing`. +Feature flags: `serde`, `json` (implies `serde`), `snapshot`, `snapshot-json`, `projection`, `projection-json`, `subscription` (the generic catch-up + live-tail loop + wake traits; **dep-free**), `export`, `import`, `cbor`. The old `testing` feature is gone — the in-memory fixtures live in the `nexus-inmemory` crate. Public sub-paths after the flatten: `nexus_store::codec::*`, `::envelope::*`, `::upcasting::*`, `::store::*`, `::state::*`, `::projection::*`, `::repository::*` (Repository + facades), `::builder::*` (builder typestate), `::snapshot::*` (decorator). All top-level types remain re-exported at `nexus_store::*` for convenience. @@ -149,17 +162,9 @@ Three macros: These rules are non-negotiable. Every one exists because of a real bug found in this codebase. -### 0. No Assumptions, No Opinions — Facts Only +### 0 · 2 · 3 · 4 · 5 · 6 · 8 — Shared devrandom rules (EXTREMELY IMPORTANT) -- **Never assume.** If you don't know something, say so and research it. Do not fill gaps with plausible-sounding guesses about APIs, crate behavior, algorithm properties, or performance characteristics. -- **Never give opinions.** No "I think," "it would be cleaner," "this feels better," "my preference is." Claims must be grounded in verifiable evidence. -- **Facts must cite sources.** Every technical claim about algorithms, data structures, concurrency, cryptography, or systems behavior must be backed by: - - **arXiv papers** (preferred for algorithmic/theoretical claims) - - **Peer-reviewed research papers** (conferences: SOSP, OSDI, VLDB, SIGMOD, PLDI, POPL, CRYPTO, etc.) - - **Primary source GitHub repositories** (the actual implementation being discussed, not blog posts about it) - - **Official specifications / RFCs** for protocols and standards -- **"Common knowledge" is not a source.** If a claim is worth making, it is worth citing. If no source exists, do not make the claim. -- **Uncertainty is a fact too.** When evidence is incomplete or contradictory, state the uncertainty explicitly rather than collapsing it into a confident-sounding opinion. +Rules **0 (No Assumptions, No Opinions — Facts Only), 2 (Arithmetic Safety), 3 (Error Handling), 4 (API Design), 5 (Concurrency Safety), 6 (Functional-First, Allocate-Last), 8 (Test Quality)** are the shared devrandom engineering rules and apply here in full. Canonical text lives in the user-global `~/.claude/CLAUDE.md` ("Engineering rules — EXTREMELY IMPORTANT"). The original numbering is preserved because the architecture notes above cite rules by number (e.g. "rule 2", "rule 3"). Only the nexus-specific rules are spelled out below. ### 1. Database Atomicity @@ -171,57 +176,12 @@ Every database interaction in store adapters MUST be atomic: If a public method does 2+ database calls without a shared transaction, it is a bug. -### 2. Arithmetic Safety - -- **No bare arithmetic** in production code. Use `checked_add`/`checked_sub` and return `Err` on overflow. `saturating_add` is banned — it silently stops progress and converts overflow into misleading downstream errors. -- **No `unwrap_or(sentinel)`** for failed conversions. `u64::try_from(x).unwrap_or(u64::MAX)` hides the root cause. Return a proper error. -- **`debug_assert` is NOT a safety check.** If an invariant violation would corrupt data or produce silently wrong results, use a runtime check (`return Err(...)` or `assert!`). `debug_assert` is compiled out in release — it protects nothing in production. Reserve it only for conditions that are provably impossible by construction (e.g., postconditions of code you just ran). - -### 3. Error Handling - -- **One variant = one failure domain.** Never jam unrelated errors into an existing variant. If upcast fails, add `StoreError::Upcast`, don't reuse `StoreError::Codec`. -- **Never erase structured errors into `Box`** when callers need to match on them. Use typed enum variants with `#[source]`. -- **Never discard the original error** with `|_|` in `map_err`. Wrap it via `#[source]`/`#[from]`, or at minimum preserve its message. -- **Never box string literals as errors.** `"some message".into()` as `Box` has no type name, no fields, no downcast target. -- **Unknown values must be `Option`, not sentinels.** When a version is unknowable (e.g., corrupt key), use `Option`, not `version: 0`. -- **Overflow/limit errors are not concurrency conflicts.** Never reuse a retry-eligible error code (`Conflict`) for a non-retryable condition (arithmetic overflow). -- **Write-path and read-path must enforce the same invariants the same way.** If the read path rejects `schema_version == 0`, the write path must too — not silently clamp it to 1. -- **`#[non_exhaustive]` is banned on enums — except public _error_ enums at the 1.0 freeze.** During experimental/unstable development it adds friction (forces `_ =>` wildcard arms) without value, and on non-error enums it stays banned forever — exhaustive matching catches real match bugs. The carve-out (#209): at the **1.0 API freeze**, every publicly-reachable **error** enum takes `#[non_exhaustive]`, because error enums are the type most likely to gain variants over a library's life and a frozen exhaustive enum is closed forever (a new variant would be a major breaking change for every downstream `match`). The attribute makes adding an error variant a minor, non-breaking change at the cost of forcing downstream wildcard arms. This is the standing exception, so `KernelError`/`StoreError`/`FjallError` et al. carrying it do **not** contradict this rule — do not "fix" them by stripping it. Error enums inside a **private** `mod` (not downstream-nameable) and all **non-error** enums are out of scope. -- **All error types must use `thiserror`.** No manual `Display` or `Error` impls on error enums/structs. `thiserror` derives are the single source of truth for error formatting and source chains. -- **Input validation errors are not corruption errors.** `EventTypeTooLong` (input error) mapped to `CorruptValue` (data error) causes wrong remediation. Use distinct variants. -- **Adapter error types must be distinct from facade error types.** `InMemoryStore::Error = StoreError` causes `StoreError::Adapter(Box::new(StoreError::...))` double-wrapping. -- **Provide `From` impls** for errors that cross crate boundaries at known mapping points. Don't manually map in 4+ places with identical code. -- **`ErrorId` truncation must be visually signaled** (e.g., append `...` if truncated). - -### 4. API Design - -- **No unused generic parameters or associated types.** If `M` is always `()` and `Aggregate::Error` is never used in any method, remove them. Add when the second concrete use case arrives. YAGNI. -- **Internal wire format helpers must be `pub(crate)`, not `pub`.** Encoding/decoding functions, size constants, and internal error types that don't appear in the public API must not be accessible to downstream crates. -- **`pub mod` leaks internals.** Use `mod` (private) with controlled `pub use` re-exports. `pub mod encoding` exposes every function in the module. -- **`#[doc(hidden)]` is not access control.** Test-only methods (`set_next_stream_id_for_testing`) must be `#[cfg(test)]` or `#[cfg(feature = "testing")]`. -- **Typestate builder intermediates** (`WithStreamId`, `WithVersion`, etc.) should not be independently constructable by users. Use `pub(crate)` on constructors or seal the module. -- **Redundant data must be eliminated or validated.** If `append(stream_id, envelopes)` takes `stream_id` AND each envelope contains `stream_id`, either remove it from the envelope or validate consistency. Silent mismatch = data corruption. -- **Type asymmetries across read/write paths must be intentional and documented.** `PendingEnvelope.stream_id() -> &StreamId` vs `PersistedEnvelope.stream_id() -> &str` is confusing. -- **Panics are for programmer bugs, not data or capacity limits.** `apply()` panicking on `MAX_UNCOMMITTED` is wrong — it's a capacity limit hit by normal usage. Return `Result`. Panics on corrupt persisted data crash the process instead of surfacing recoverable errors. -- **Rust naming conventions matter.** `new_unchecked` means no validation (caller guarantees preconditions). If it `assert!`s and panics, it's `new`, not `new_unchecked`. -- **`pub(crate)` fields → constructor.** Don't construct structs via struct literal syntax from other modules. Provide a `pub(crate) fn new(...)` so adding a field doesn't break every construction site. -- **Trait contracts must document semantics.** `read_stream(from)` — is `from` inclusive or exclusive? `Version::INITIAL` (0) — is it a valid event version or only a sentinel? Document it on the trait, not in one implementation. -- **Each crate validates at its own boundary.** The store crate must not trust kernel guarantees (version >= 1) without its own check. The fjall crate must not trust store guarantees. Every crate defends itself. - -### 5. Concurrency Safety - -- **`Relaxed` memory ordering requires structural proof**, not a comment about external library behavior. If correctness depends on fjall's `write_tx()` serializing writers, that's an undocumented coupling. Use `Acquire`/`Release` or a mutex to make the invariant self-contained. - -### 6. Code Style — Functional-First, Allocate-Last - -- **Prefer combinators over imperative control flow.** Use `.map()`, `.and_then()`, `.map_or_else()`, `.filter()`, `.fold()`, and other iterator/`Option`/`Result` combinators instead of `if`/`else`/`match` when the transformation is a simple data flow. Reserve imperative style for cases where it measurably improves performance or enables compile-time safety that combinators cannot express. -- **Lazy over eager.** Use iterators and lazy chains instead of collecting into intermediate `Vec`s. Only `.collect()` when you need the collection as a concrete value. `stream.filter().map().take()` is preferred over `let mut v = Vec::new(); for x in stream { if cond { v.push(f(x)); } }`. -- **Borrow before own.** Default to `&T` and lifetimes. Only clone/allocate when borrowing is impossible (crossing `async` boundaries, returning owned data to callers, storing in long-lived containers). `Cow<'a, T>` bridges the gap when ownership is conditionally needed. -- **No gratuitous allocations.** Every `Vec::new()`, `.to_owned()`, `.to_string()`, `Box::new()`, or `.clone()` in a hot path must justify itself. Prefer stack allocation (`ArrayVec`, `SmallVec`, `[T; N]`) for bounded collections. Use `&str` over `String`, `&[u8]` over `Vec`. -- **Extension traits for GAT streams.** Our `EventStream` uses GAT lending iterators where standard `Iterator` combinators aren't available. Add extension trait methods (e.g., `EventStreamExt`) to keep call sites clean and composable rather than forcing every consumer into imperative `while let` loops. -- **`let ... else` over `if let ... else { return }`.** When the `else` branch is an early return/error, `let ... else` eliminates a nesting level and makes the happy path primary. -- **Collapse redundant branches.** When both arms of an `if/else` compute the same expression except at a single boundary value, and the expressions produce identical results at that boundary, collapse them into one expression. -- **All `use` imports at the top of the file.** Never scatter `use` statements mid-file. Never use deep inline path qualification like `crate::error::AppendError::Conflict { .. }` in match arms or expressions — import the type at the top and use the short name. The only exception is a genuinely one-off reference where adding an import would be more confusing than the inline path. +Nexus-specific addenda to the shared rules: + +- **Rule 3 addendum — `#[non_exhaustive]` is banned on enums, except public _error_ enums at the 1.0 freeze.** During experimental development it adds friction without value; on non-error enums it stays banned forever (exhaustive matching catches real bugs). The carve-out (#209): at the 1.0 API freeze, every publicly-reachable **error** enum takes `#[non_exhaustive]` so adding a variant stays a minor change. `KernelError`/`StoreError`/`FjallError` et al. carrying it do **not** contradict the rule — do not "fix" them by stripping it. Private-mod error enums and non-error enums are out of scope. +- **Rule 3 addendum — adapter error types must be distinct from facade error types** (`InMemoryStore::Error = StoreError` causes double-wrapping); provide `From` impls at known crate-boundary mapping points; `ErrorId` truncation must be visually signaled. +- **Rule 4 addendum** — typestate builder intermediates are not independently constructable (seal or `pub(crate)` constructors); redundant data eliminated or validated (silent mismatch = corruption); type asymmetries across read/write paths intentional and documented; `pub(crate)` fields get a constructor so adding a field doesn't break construction sites. +- **Rule 6 addendum** — extension traits for GAT streams (`EventStreamExt`) keep call sites composable; collapse redundant branches that agree at the boundary value. ### 7. Testing — 4 Cross-Cutting Categories (highest priority) @@ -236,19 +196,7 @@ After the 4 categories above, apply the 21 testing methodologies (see test strat ### 8. Test Quality Rules -Every test must satisfy ALL of these: - -- **Calls the actual SUT.** Don't reimplement production logic in the test and prove properties of the reimplementation. Don't write a custom `ProbeStore` when `InMemoryStore` or `FjallStore` exist. Call the real function with the real dangerous input. -- **Can actually fail.** If both branches of a conditional pass (`if empty { println!(...) } else { assert!(...) }`), the test is worthless. Every test must have an assertion that would fail if the invariant breaks. -- **Asserts the specific correct result**, not "something happened." `assert!(msg.contains('3'))` is not an assertion — it matches any string containing `3`. Use `assert_eq!` with exact expected values. -- **`println!` is not an assertion.** Data loss, corruption, and safety violations must be asserted on, not logged. -- **"Concurrent" tests must have actual concurrency.** `tokio::spawn` + `Barrier` to ensure operations overlap. Sequential-then-check is not a concurrency test. -- **Property test ranges must include boundaries.** Every proptest strategy must include `0`, `1`, `MAX-1`, `MAX` via `prop_oneof!` alongside the interior range. For strings/IDs: empty, max-length, max-length+1. -- **State machine tests must include concurrent mode** if the SUT is concurrent. -- **Bug probe tests must FAIL when the bug exists**, not pass. If a known bug is accepted, use `#[ignore]`, not a green test that documents the issue in comments. -- **Each invariant tested once in a canonical location.** Don't duplicate the same property test across 3-5 files with different domain types. -- **Benchmarks measure production code**, not test fixtures. Use official testing adapters, not hand-rolled ones. Separate setup from measurement — don't benchmark `tempdir() + open()` when you want to measure `append()`. -- **No `Box::leak` in proptest** without documentation and bounded iteration counts. +Shared rule — see the global CLAUDE.md. Nexus addenda: don't write a custom `ProbeStore` when `InMemoryStore`/`FjallStore` exist; state-machine tests include concurrent mode if the SUT is concurrent; no `Box::leak` in proptest without documentation and bounded iteration counts. ### 9. Architectural Decisions — Measure, Question, Decide (principal-engineer bar) diff --git a/Cargo.lock b/Cargo.lock index c6804956..4f0a2ce9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,6 +1197,7 @@ dependencies = [ "bytes", "futures", "nexus", + "nexus-inmemory", "nexus-store", "thiserror", "tokio", @@ -1210,6 +1211,7 @@ dependencies = [ "bytes", "futures", "nexus", + "nexus-inmemory", "nexus-store", "serde", "serde_json", @@ -1225,6 +1227,7 @@ dependencies = [ "bytes", "futures", "nexus", + "nexus-inmemory", "nexus-store", "serde", "serde_json", @@ -1247,6 +1250,7 @@ dependencies = [ "nexus-fjall", "nexus-store", "nexus-store-testing", + "nexus-wake", "proptest", "proptest-state-machine", "tempfile", @@ -1255,6 +1259,22 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "nexus-inmemory" +version = "0.1.0" +dependencies = [ + "bytes", + "futures", + "nexus", + "nexus-store", + "nexus-store-testing", + "nexus-wake", + "proptest", + "thiserror", + "tokio", + "workspace-hack", +] + [[package]] name = "nexus-macros" version = "0.1.0" @@ -1287,6 +1307,7 @@ dependencies = [ "nexus", "nexus-store", "nexus-store-testing", + "nexus-wake", "sqlx", "thiserror", "tokio", @@ -1303,15 +1324,14 @@ dependencies = [ "bytes", "crc32c", "criterion", - "foldhash 0.2.0", "futures", "futures-core", "insta", "minicbor", "nexus", + "nexus-inmemory", "nexus-store", "nexus-store-testing", - "parking_lot", "proptest", "rkyv", "serde", @@ -1334,6 +1354,18 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "nexus-wake" +version = "0.1.0" +dependencies = [ + "foldhash 0.2.0", + "nexus-store", + "parking_lot", + "thiserror", + "tokio", + "workspace-hack", +] + [[package]] name = "num-traits" version = "0.2.19" diff --git a/Cargo.toml b/Cargo.toml index 34249e91..d64a3f13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,12 +3,14 @@ resolver = "2" members = [ "crates/nexus", "crates/nexus-fjall", + "crates/nexus-inmemory", "crates/nexus-macros", "crates/nexus-macros/tests/cross_crate_test", "crates/nexus-nostd-smoketest", "crates/nexus-postgres", "crates/nexus-store", "crates/nexus-store-testing", + "crates/nexus-wake", "crates/workspace-hack", "examples/closing-the-books", "examples/fjall-end-to-end", diff --git a/crates/nexus-fjall/Cargo.toml b/crates/nexus-fjall/Cargo.toml index 79992104..d270837a 100644 --- a/crates/nexus-fjall/Cargo.toml +++ b/crates/nexus-fjall/Cargo.toml @@ -24,6 +24,7 @@ fjall = { workspace = true } futures.workspace = true nexus = { version = "0.1.0", path = "../nexus" } nexus-store = { version = "0.1.0", path = "../nexus-store", features = ["subscription"] } +nexus-wake = { version = "0.1.0", path = "../nexus-wake" } thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["sync"] } workspace-hack = { version = "0.1", path = "../workspace-hack" } diff --git a/crates/nexus-fjall/src/builder.rs b/crates/nexus-fjall/src/builder.rs index 023a84c2..bfbaddd9 100644 --- a/crates/nexus-fjall/src/builder.rs +++ b/crates/nexus-fjall/src/builder.rs @@ -4,7 +4,7 @@ use crate::partition::projection_defaults; use crate::partition::{AllIndex, KeyspaceConfig, Partitions, point_read_defaults, scan_defaults}; use crate::store::FjallStore; use fjall::KeyspaceCreateOptions; -use nexus_store::notify::StreamNotifiers; +use nexus_wake::StreamNotifiers; use std::path::{Path, PathBuf}; /// Builder for [`FjallStore`]. diff --git a/crates/nexus-fjall/src/error.rs b/crates/nexus-fjall/src/error.rs index 0ab51efc..ee06a0c5 100644 --- a/crates/nexus-fjall/src/error.rs +++ b/crates/nexus-fjall/src/error.rs @@ -71,11 +71,11 @@ pub enum FjallError { /// Failed to register a per-stream subscription wake handle. /// - /// Surfaces [`NotifyError`](nexus_store::notify::NotifyError) from the + /// Surfaces [`NotifyError`](nexus_wake::NotifyError) from the /// wake registry — in practice only the unreachable subscriber-count /// overflow. #[error("subscription wake registration failed")] - Subscription(#[from] nexus_store::notify::NotifyError), + Subscription(#[from] nexus_wake::NotifyError), } #[cfg(test)] diff --git a/crates/nexus-fjall/src/store.rs b/crates/nexus-fjall/src/store.rs index bee042bb..bf4db239 100644 --- a/crates/nexus-fjall/src/store.rs +++ b/crates/nexus-fjall/src/store.rs @@ -9,9 +9,9 @@ use nexus::{ErrorId, Version}; use nexus_store::PendingEnvelope; use nexus_store::StreamKey; use nexus_store::error::AppendError; -use nexus_store::notify::{NotifyError, StreamNotifiers, WakeReg}; use nexus_store::store::RawEventStore; use nexus_store::wake::WakeSource; +use nexus_wake::{NotifyError, StreamNotifiers, WakeReg}; use std::path::Path; use std::sync::Arc; diff --git a/crates/nexus-inmemory/Cargo.toml b/crates/nexus-inmemory/Cargo.toml new file mode 100644 index 00000000..5dbb1ca4 --- /dev/null +++ b/crates/nexus-inmemory/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "nexus-inmemory" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "In-memory event-store adapter for the Nexus event-sourcing framework" +readme = "../../README.md" +keywords = ["event-sourcing", "event-store", "in-memory", "testing"] +categories = ["data-structures", "development-tools::testing"] + +[features] +export = ["nexus-store/export"] +import = ["nexus-store/import"] + +[dependencies] +bytes = { workspace = true } +futures = { workspace = true } +nexus = { version = "0.1.0", path = "../nexus" } +nexus-store = { version = "0.1.0", path = "../nexus-store", features = ["subscription"] } +nexus-wake = { version = "0.1.0", path = "../nexus-wake" } +thiserror = { workspace = true, features = ["std"] } +tokio = { workspace = true, features = ["sync"] } +workspace-hack = { version = "0.1", path = "../workspace-hack" } + +[dev-dependencies] +nexus-store-testing = { version = "0.1.0", path = "../nexus-store-testing" } +proptest = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/nexus-store/src/testing.rs b/crates/nexus-inmemory/src/lib.rs similarity index 92% rename from crates/nexus-store/src/testing.rs rename to crates/nexus-inmemory/src/lib.rs index 6d8183b2..b72d498b 100644 --- a/crates/nexus-store/src/testing.rs +++ b/crates/nexus-inmemory/src/lib.rs @@ -1,19 +1,35 @@ -//! Test utilities for nexus-store. Gated behind the `testing` feature. +//! In-memory event-store adapter for `nexus-store`. +//! +//! [`InMemoryStore`] implements the full adapter surface — +//! [`RawEventStore`], [`WakeSource`] (via [`nexus_wake::StreamNotifiers`]), +//! `StreamLister`/`AtomicAppend` behind the `export`/`import` features — and +//! [`InMemorySnapshotStore`] implements `SnapshotStore`. Together they are the +//! reference in-process adapter: business-logic tests, examples, and the +//! white-box tests of `nexus-store` itself all run on them. +//! +//! Like every adapter, rows are built via `nexus_store::wire::encode_frame`, +//! so the stored bytes are the canonical frame format (payload 16-byte +//! aligned), not a shortcut representation. -use crate::batch::BatchSize; -use crate::envelope::{EnvelopeError, PendingEnvelope, PersistedEnvelope}; -use crate::error::AppendError; -#[cfg(feature = "import")] -use crate::import::{AtomicAppend, AtomicAppendError, PlannedAppend}; -use crate::notify::{NotifyError, StreamNotifiers, WakeReg}; -use crate::store::{AllPosition, RawEventStore}; -use crate::wake::WakeSource; -use crate::wire::{self, FrameOffsets}; use bytes::Bytes; use nexus::ErrorId; use nexus::Version; +use nexus_store::batch::BatchSize; +use nexus_store::envelope::{EnvelopeError, PendingEnvelope, PersistedEnvelope}; +use nexus_store::error::AppendError; +#[cfg(feature = "import")] +use nexus_store::import::{AtomicAppend, AtomicAppendError, PlannedAppend}; +use nexus_store::store::{AllPosition, RawEventStore}; +use nexus_store::value::SchemaVersion; +use nexus_store::wake::WakeSource; +use nexus_store::wire::{self, FrameOffsets}; +use nexus_wake::{NotifyError, StreamNotifiers, WakeReg}; + +mod snapshot; + +pub use snapshot::InMemorySnapshotStore; -use crate::stream_id::StreamKey; +use nexus_store::stream_id::StreamKey; use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::VecDeque; @@ -51,18 +67,13 @@ pub enum InMemoryStoreError { source: EnvelopeError, }, - /// Stored row carries `schema_version == 0` — structurally corrupt - /// (the write path uses `SchemaVersion`, which is `NonZeroU32`). - #[error("corrupt schema_version on stored row at version {version}: got 0, must be > 0")] - CorruptSchemaVersion { version: u64 }, - /// Failed to register a per-stream subscription wake handle. #[error("subscription wake registration failed")] Subscription(#[from] NotifyError), } /// [`InMemoryStore`]'s `$all` resume position — its -/// [`AllPosition`](crate::AllPosition). +/// [`AllPosition`](nexus_store::AllPosition). /// /// A monotonic-but-gappy `NonZeroU64`, the in-memory analogue of fjall's /// `GlobalSeq`. It is the key of the store's `$all` index (`global_index`), so @@ -116,6 +127,10 @@ struct StoredFrame { /// Wire-format frame (see [`wire::encode_frame`]). Payload is 16-byte aligned. value: Bytes, offsets: FrameOffsets, + /// Captured typed at append time (like `offsets`), so the read path never + /// re-parses header bytes — the frame's byte layout stays private to + /// `nexus_store::wire`. + schema_version: SchemaVersion, } /// In-memory event store for testing. Implements [`RawEventStore`]. @@ -302,54 +317,37 @@ impl futures::Stream for InMemoryAllStream { /// Delegates to [`wire::encode_frame`], which produces a 16-byte-aligned /// payload inside the resulting [`Bytes`]. The `$all` position is **not** /// encoded into the frame — the store keys its `global_index` by it instead. -fn encode_pending_to_frame( - env: &PendingEnvelope, -) -> Result> { +fn encode_pending_to_frame(env: &PendingEnvelope) -> Result { let frame = wire::encode_frame( env.schema_version_value(), &env.event_type_value(), &env.payload_value(), env.metadata_value().as_ref(), ) - .map_err(|e| AppendError::Store(InMemoryStoreError::Wire(e)))?; + .map_err(InMemoryStoreError::Wire)?; Ok(StoredFrame { version: env.version().as_u64(), value: frame.value, offsets: frame.offsets, + schema_version: env.schema_version_value(), }) } /// Construct a [`PersistedEnvelope`] from a [`StoredFrame`]. /// -/// Reads `schema_version` from the wire-format header at the constant offset -/// defined by [`wire`]. The `$all` position is not in the frame — callers that -/// need it read it from the `global_index` key. +/// `schema_version` comes from the frame's typed field (captured at append +/// time), never by re-parsing header bytes. The `$all` position is not in the +/// frame — callers that need it read it from the `global_index` key. fn frame_to_envelope(frame: &StoredFrame) -> Result { let Some(version) = Version::new(frame.version) else { return Err(InMemoryStoreError::CorruptVersion); }; - let value = &frame.value; - // Bytes are read individually so no fallible try_into sits in the - // cursor hot path. The wire::encode_frame invariant guarantees these - // offsets are present in any non-empty StoredFrame value. - let schema_version_raw = u32::from_le_bytes([ - value[wire::SCHEMA_VERSION_OFFSET], - value[wire::SCHEMA_VERSION_OFFSET + 1], - value[wire::SCHEMA_VERSION_OFFSET + 2], - value[wire::SCHEMA_VERSION_OFFSET + 3], - ]); - let schema_version = - crate::value::SchemaVersion::from_u32(schema_version_raw).map_err(|_| { - InMemoryStoreError::CorruptSchemaVersion { - version: frame.version, - } - })?; PersistedEnvelope::try_new( version, frame.value.clone(), - schema_version, + frame.schema_version, frame.offsets.event_type.clone(), frame.offsets.payload.clone(), frame.offsets.metadata.clone(), @@ -429,7 +427,10 @@ impl RawEventStore for InMemoryStore { let mut seq = *counter; let mut rows: Vec<(InMemoryAllPos, StoredFrame)> = Vec::with_capacity(envelopes.len()); for env in envelopes { - rows.push((seq, encode_pending_to_frame(env)?)); + rows.push(( + seq, + encode_pending_to_frame(env).map_err(AppendError::Store)?, + )); seq = seq .next() .ok_or(AppendError::Store(InMemoryStoreError::GlobalSeqOverflow))?; @@ -610,17 +611,17 @@ impl WakeSource for InMemoryStore { /// cursor. `InMemoryStore` is a test store, so materializing all ids at once /// is acceptable; a real adapter (fjall, postgres) streams them lazily. #[cfg(feature = "export")] -impl crate::export::StreamLister for InMemoryStore { +impl nexus_store::export::StreamLister for InMemoryStore { type StreamList = futures::stream::Iter< - std::vec::IntoIter>, + std::vec::IntoIter>, >; async fn list_streams(&self) -> Result { - let ids: Vec> = { + let ids: Vec> = { let guard = self.streams.lock().await; guard .keys() - .map(|k| Ok(crate::stream_id::StreamKey::from_slice(k.as_bytes()))) + .map(|k| Ok(nexus_store::stream_id::StreamKey::from_slice(k.as_bytes()))) .collect() }; Ok(futures::stream::iter(ids)) @@ -703,17 +704,7 @@ impl AtomicAppend for InMemoryStore { for w in writes { let mut frames = Vec::with_capacity(w.events.len()); for env in &w.events { - let frame = encode_pending_to_frame(env).map_err(|e| match e { - AppendError::Store(s) => AtomicAppendError::Store(s), - // encode_pending_to_frame only ever returns AppendError::Store - // (wire-format failure); it never does a head check and so can - // never produce Conflict. Mapped defensively so the match is - // exhaustive rather than relying on a private implementation - // detail of encode_pending_to_frame. - AppendError::Conflict { .. } => { - AtomicAppendError::Store(InMemoryStoreError::VersionOverflow) - } - })?; + let frame = encode_pending_to_frame(env).map_err(AtomicAppendError::Store)?; staged_global.push((seq, frame.clone())); frames.push(frame); seq = seq.next().ok_or(AtomicAppendError::Store( @@ -753,7 +744,7 @@ impl AtomicAppend for InMemoryStore { #[allow(clippy::unwrap_used, reason = "test code")] mod batch_config_tests { use super::*; - use crate::batch::{BatchSize, DEFAULT_BATCH}; + use nexus_store::batch::{BatchSize, DEFAULT_BATCH}; #[test] fn default_store_uses_default_batch() { @@ -777,9 +768,9 @@ mod batch_config_tests { )] mod bounded_read_tests { use super::*; - use crate::batch::BatchSize; - use crate::envelope::pending_envelope; use futures::StreamExt; + use nexus_store::batch::BatchSize; + use nexus_store::envelope::pending_envelope; fn env(v: u64) -> PendingEnvelope { pending_envelope(Version::new(v).unwrap()) @@ -880,9 +871,9 @@ mod bounded_read_tests { )] mod global_read_tests { use super::*; - use crate::envelope::pending_envelope; - use crate::{StepStreamExt, Store, Subscription}; use futures::StreamExt; + use nexus_store::envelope::pending_envelope; + use nexus_store::{StepStreamExt, Store, Subscription}; fn sk(s: &str) -> StreamKey { StreamKey::from_slice(s.as_bytes()) @@ -1039,11 +1030,11 @@ mod global_read_tests { )] mod bounded_subscription_tests { use super::*; - use crate::Store; - use crate::batch::BatchSize; - use crate::envelope::pending_envelope; - use crate::{StepStreamExt, Subscription}; use futures::StreamExt; + use nexus_store::Store; + use nexus_store::batch::BatchSize; + use nexus_store::envelope::pending_envelope; + use nexus_store::{StepStreamExt, Subscription}; fn env(v: u64) -> PendingEnvelope { pending_envelope(Version::new(v).unwrap()) @@ -1096,8 +1087,8 @@ mod bounded_subscription_tests { #[allow(clippy::expect_used, reason = "test code")] mod wake_source_tests { use super::*; - use crate::envelope::pending_envelope; - use crate::wake::{WakeRegistration, WakeSource}; + use nexus_store::envelope::pending_envelope; + use nexus_store::wake::{WakeRegistration, WakeSource}; use std::time::Duration; use tokio::time::timeout; diff --git a/crates/nexus-inmemory/src/snapshot.rs b/crates/nexus-inmemory/src/snapshot.rs new file mode 100644 index 00000000..53744533 --- /dev/null +++ b/crates/nexus-inmemory/src/snapshot.rs @@ -0,0 +1,65 @@ +//! In-memory [`SnapshotStore`] — the snapshot/projection-state counterpart of +//! [`InMemoryStore`](crate::InMemoryStore). + +use std::collections::HashMap; +use std::convert::Infallible; +use std::num::NonZeroU32; + +use nexus::Id; +use nexus_store::state::{Hydrated, SnapshotStore}; +use tokio::sync::RwLock; + +/// In-memory snapshot store for tests. +#[derive(Debug, Default)] +pub struct InMemorySnapshotStore { + snapshots: RwLock>, +} + +impl InMemorySnapshotStore { + #[must_use] + pub fn new() -> Self { + Self { + snapshots: RwLock::new(HashMap::new()), + } + } +} + +impl SnapshotStore for InMemorySnapshotStore +where + S: Clone + Send + Sync + 'static, + P: Clone + Send + Sync + 'static, +{ + type Error = Infallible; + + async fn hydrate( + &self, + id: &impl Id, + schema_version: NonZeroU32, + ) -> Result, Infallible> { + let snapshots = self.snapshots.read().await; + Ok(match snapshots.get(&id.to_string()) { + None => Hydrated::Absent, + Some((stored_schema, _, _)) if *stored_schema != schema_version => Hydrated::Stale { + stored_schema: *stored_schema, + }, + Some((_, position, state)) => Hydrated::Found { + position: position.clone(), + state: state.clone(), + }, + }) + } + + async fn commit( + &self, + id: &impl Id, + schema_version: NonZeroU32, + position: P, + state: &S, + ) -> Result<(), Infallible> { + self.snapshots + .write() + .await + .insert(id.to_string(), (schema_version, position, state.clone())); + Ok(()) + } +} diff --git a/crates/nexus-store/tests/inmemory_conformance.rs b/crates/nexus-inmemory/tests/inmemory_conformance.rs similarity index 98% rename from crates/nexus-store/tests/inmemory_conformance.rs rename to crates/nexus-inmemory/tests/inmemory_conformance.rs index 9f54a659..53cf4477 100644 --- a/crates/nexus-store/tests/inmemory_conformance.rs +++ b/crates/nexus-inmemory/tests/inmemory_conformance.rs @@ -11,9 +11,9 @@ use std::num::NonZeroU32; +use nexus_inmemory::InMemoryStore; use nexus_store::envelope::pending_envelope; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::value::SchemaVersion; use nexus_store::{PendingEnvelope, StreamKey, Version}; use nexus_store_testing::{ diff --git a/crates/nexus-store/tests/inmemory_store_tests.rs b/crates/nexus-inmemory/tests/inmemory_store_tests.rs similarity index 98% rename from crates/nexus-store/tests/inmemory_store_tests.rs rename to crates/nexus-inmemory/tests/inmemory_store_tests.rs index 07e5325e..e343c03f 100644 --- a/crates/nexus-store/tests/inmemory_store_tests.rs +++ b/crates/nexus-inmemory/tests/inmemory_store_tests.rs @@ -1,14 +1,13 @@ -#![cfg(feature = "testing")] #![allow(clippy::unwrap_used, reason = "tests")] #![allow(clippy::panic, reason = "tests")] use futures::StreamExt; use nexus::Version; +use nexus_inmemory::{InMemoryAllPos, InMemoryStore}; use nexus_store::AppendError; use nexus_store::StreamKey; use nexus_store::pending_envelope; use nexus_store::store::RawEventStore; -use nexus_store::testing::{InMemoryAllPos, InMemoryStore}; #[tokio::test] async fn append_conflict_truncates_overlong_stream_id_with_ellipsis() { diff --git a/crates/nexus-postgres/Cargo.toml b/crates/nexus-postgres/Cargo.toml index e82907cb..e94d9d82 100644 --- a/crates/nexus-postgres/Cargo.toml +++ b/crates/nexus-postgres/Cargo.toml @@ -16,6 +16,7 @@ bytes = { workspace = true } futures = { workspace = true } nexus = { version = "0.1.0", path = "../nexus" } nexus-store = { version = "0.1.0", path = "../nexus-store", features = ["subscription"] } +nexus-wake = { version = "0.1.0", path = "../nexus-wake" } sqlx = { workspace = true } thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["rt", "sync"] } diff --git a/crates/nexus-postgres/src/store.rs b/crates/nexus-postgres/src/store.rs index 463c3a1a..67ab36f2 100644 --- a/crates/nexus-postgres/src/store.rs +++ b/crates/nexus-postgres/src/store.rs @@ -7,10 +7,10 @@ use nexus_store::PendingEnvelope; use nexus_store::StreamKey; use nexus_store::envelope::PersistedEnvelope; use nexus_store::error::AppendError; -use nexus_store::notify::StreamNotifiers; use nexus_store::store::RawEventStore; use nexus_store::value::{EventType, Metadata, Payload, SchemaVersion}; use nexus_store::wire; +use nexus_wake::StreamNotifiers; use sqlx::PgPool; use sqlx::postgres::PgListener; use tokio::task::JoinHandle; diff --git a/crates/nexus-postgres/src/wake.rs b/crates/nexus-postgres/src/wake.rs index 39fd6c52..e0aa1627 100644 --- a/crates/nexus-postgres/src/wake.rs +++ b/crates/nexus-postgres/src/wake.rs @@ -12,8 +12,8 @@ //! note). `Registration` and `Error` are therefore the registry's own //! [`WakeReg`] / [`NotifyError`] — no postgres-specific wake types are needed. -use nexus_store::notify::{NotifyError, WakeReg}; use nexus_store::wake::WakeSource; +use nexus_wake::{NotifyError, WakeReg}; use crate::store::PostgresStore; diff --git a/crates/nexus-store/Cargo.toml b/crates/nexus-store/Cargo.toml index 435deb82..e3a357b7 100644 --- a/crates/nexus-store/Cargo.toml +++ b/crates/nexus-store/Cargo.toml @@ -12,15 +12,11 @@ keywords = ["event-sourcing", "event-store", "cqrs", "persistence"] categories = ["data-structures"] [features] -# Per-stream subscription wake path (`notify` module). Pulls `tokio` (for its -# `Notify` sync primitive), `foldhash` (fast hot-path hasher), and `parking_lot` -# (sync `Mutex` lockable from the drop-guard's `Drop`). Adapter crates that -# implement `WakeSource` enable this; pure event-sourcing users -# (repository/codec/wire only) build with no tokio dependency at all. -subscription = ["dep:tokio", "dep:foldhash", "dep:parking_lot"] -# `testing` ships `InMemoryStore`, whose subscription cursor uses the wake -# registry, so it implies `subscription`. -testing = ["subscription"] +# Generic catch-up-then-live-tail subscription loop + the `wake` traits. +# Dep-free: the loop is generic over `WakeSource`, so no runtime is pulled. +# The in-process wake registry (`StreamNotifiers`, tokio-backed) lives in the +# `nexus-wake` crate; the in-memory adapter lives in `nexus-inmemory`. +subscription = [] serde = ["dep:serde"] json = ["serde", "dep:serde_json"] snapshot = [] @@ -50,17 +46,14 @@ arrayvec = { workspace = true } bytemuck = { workspace = true, optional = true } bytes = { workspace = true } crc32c = { workspace = true, optional = true } -foldhash = { workspace = true, optional = true } futures = { workspace = true } futures-core = { workspace = true } minicbor = { workspace = true, features = ["derive"], optional = true } nexus = { version = "0.1.0", path = "../nexus" } -parking_lot = { workspace = true, optional = true } rkyv = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } thiserror = { workspace = true, features = ["std"] } -tokio = { workspace = true, features = ["sync"], optional = true } workspace-hack = { version = "0.1", path = "../workspace-hack" } [dev-dependencies] @@ -72,7 +65,11 @@ insta = { workspace = true } # `snapshot-json` the `snapshot`-gated test files (snapshot_tests.rs, # snapshot_integration_tests.rs) never compiled or ran in CI — nothing in the # default workspace graph enables `snapshot`. See issue #262. -nexus-store = { path = ".", features = ["testing", "export", "import", "cbor", "snapshot-json", "projection"] } +nexus-store = { path = ".", features = ["subscription", "export", "import", "cbor", "snapshot-json", "projection"] } +# In-memory adapter for white-box tests. Path-only (no version): a dev-dep +# cycle (nexus-inmemory depends on nexus-store) is legal for cargo and the +# path-only form is stripped at publish time. +nexus-inmemory = { path = "../nexus-inmemory", features = ["export", "import"] } nexus-store-testing = { version = "0.1.0", path = "../nexus-store-testing" } proptest = { workspace = true } serde = { workspace = true } diff --git a/crates/nexus-store/src/catchup.rs b/crates/nexus-store/src/catchup.rs index 7a5e9002..ff4109df 100644 --- a/crates/nexus-store/src/catchup.rs +++ b/crates/nexus-store/src/catchup.rs @@ -174,15 +174,15 @@ impl Catchup for AllCatchup { } } -#[cfg(all(test, feature = "testing"))] +#[cfg(test)] #[allow(clippy::unwrap_used, reason = "test code")] mod tests { use super::*; use crate::envelope::pending_envelope; - use crate::testing::InMemoryStore; + use crate::test_support::TestStore; use futures::StreamExt; - async fn seed(store: &InMemoryStore, id: &StreamKey, lo: u64, hi: u64) { + async fn seed(store: &TestStore, id: &StreamKey, lo: u64, hi: u64) { for v in lo..=hi { let env = pending_envelope(Version::new(v).unwrap()) .event_type("E") @@ -198,7 +198,7 @@ mod tests { /// the envelope's own version. #[tokio::test] async fn stream_catchup_reads_after_none() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); let id = StreamKey::from_slice(b"s"); seed(&store, &id, 1, 3).await; @@ -223,7 +223,7 @@ mod tests { /// seeding [1,2,3] and resuming after version 2 yields only [3]. #[tokio::test] async fn stream_catchup_read_after_is_exclusive() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); let id = StreamKey::from_slice(b"s"); seed(&store, &id, 1, 3).await; @@ -245,7 +245,7 @@ mod tests { /// last-delivered position must NOT re-deliver event 3. #[tokio::test] async fn reopen_does_not_redeliver_last_event() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); let id = StreamKey::from_slice(b"s"); seed(&store, &id, 1, 3).await; @@ -281,7 +281,7 @@ mod tests { /// empty-scan branch that replaces the old `next_pos` overflow→park). #[tokio::test] async fn stream_catchup_read_after_max_is_empty() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); let id = StreamKey::from_slice(b"s"); seed(&store, &id, 1, 1).await; @@ -299,7 +299,7 @@ mod tests { /// `read_after(Some(p))` is exclusive (strictly after `p`). #[tokio::test] async fn all_catchup_reads_after_none_then_exclusive() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); // Interleave so `$all` order spans both streams: a@1, b@1, a@2. seed(&store, &StreamKey::from_slice(b"a"), 1, 1).await; seed(&store, &StreamKey::from_slice(b"b"), 1, 1).await; @@ -314,7 +314,7 @@ mod tests { .unwrap(); let catchup = AllCatchup::new(Arc::clone(&store)).unwrap(); - let all: Vec<(crate::testing::InMemoryAllPos, PersistedEnvelope)> = catchup + let all: Vec<(crate::test_support::TestAllPos, PersistedEnvelope)> = catchup .read_after(None) .await .unwrap() diff --git a/crates/nexus-store/src/cbor.rs b/crates/nexus-store/src/cbor.rs index defd0c19..7f59986f 100644 --- a/crates/nexus-store/src/cbor.rs +++ b/crates/nexus-store/src/cbor.rs @@ -401,11 +401,6 @@ pub fn decode_chunk(bytes: &[u8]) -> Result, ChunkError> { )] mod tests { use super::*; - use crate::envelope::pending_envelope; - use crate::import::{Atomicity, EventImporter}; - use crate::store::RawEventStore; - use crate::stream_id::StreamKey; - use crate::testing::InMemoryStore; #[test] fn writer_multi_section_round_trips() { @@ -1042,77 +1037,6 @@ mod tests { } } - // ── Task 7: Full pipeline — export → box → import ─────────────────────── - - #[cfg(feature = "testing")] - #[tokio::test] - async fn export_box_import_round_trip_byte_equal_modulo_global_seq() { - // Seed a source store with two streams. - let src = InMemoryStore::new(); - for (sid, count) in [("task-1", 3u64), ("task-2", 2)] { - for v in 1..=count { - let pe = pending_envelope(Version::new(v).expect("nonzero")) - .event_type("E") - .payload(format!("{sid}-{v}").into_bytes()) - .build() - .expect("valid envelope"); - src.append( - &StreamKey::from_slice(sid.as_bytes()), - Version::new(v - 1), - core::slice::from_ref(&pe), - ) - .await - .expect("append"); - } - } - - // Export → box-encode into one chunk. - let mut w = ChunkWriter::new(Vec::new(), Some(b"src")).expect("writer"); - for sid in ["task-1", "task-2"] { - let s = src - .read_stream(&StreamKey::from_slice(sid.as_bytes()), Version::INITIAL) - .await - .expect("read"); - w.section(sid.as_bytes()) - .expect("section") - .try_extend(s) - .await - .expect("extend"); - } - let chunk = w.into_sink(); - - // Box-decode → import into a fresh store under origin-namespaced ids. - let sections = decode_chunk(&chunk).expect("decode"); - let dst = InMemoryStore::new(); - let route = |origin: &[u8]| { - StreamKey::from_slice(format!("src:{}", String::from_utf8_lossy(origin)).as_bytes()) - }; - let report = dst - .import(§ions, route, Atomicity::PerStream) - .await - .expect("import"); - assert!(report.all_complete()); - - // Verify byte-equality of payloads/versions modulo global_seq. - for sid in ["task-1", "task-2"] { - let target = StreamKey::from_slice(format!("src:{sid}").as_bytes()); - let got: Vec<(u64, Vec)> = dst - .read_stream(&target, Version::INITIAL) - .await - .expect("read") - .map(|r| { - let e = r.expect("no err"); - (e.version().as_u64(), e.payload().to_vec()) - }) - .collect() - .await; - let expected: Vec<(u64, Vec)> = (1..=if sid == "task-1" { 3u64 } else { 2 }) - .map(|v| (v, format!("{sid}-{v}").into_bytes())) - .collect(); - assert_eq!(got, expected, "stream {sid} round-trips"); - } - } - // ── #2: CBOR length-form boundaries (deterministic) ────────────────────── // CBOR encodes a byte/text-string length with a different prefix per size // band: inline (<24), 1-byte (24..=255), 2-byte (256..=65535), 4-byte diff --git a/crates/nexus-store/src/decoded.rs b/crates/nexus-store/src/decoded.rs index 793ec458..3432b12f 100644 --- a/crates/nexus-store/src/decoded.rs +++ b/crates/nexus-store/src/decoded.rs @@ -319,108 +319,3 @@ where I: RawItem, { } - -#[cfg(test)] -#[allow(clippy::unwrap_used, reason = "tests")] -#[allow(clippy::expect_used, reason = "tests")] -mod tests { - use super::*; - use crate::Store; - use crate::pending_envelope; - use crate::store::RawEventStore; - use crate::stream_id::StreamKey; - use crate::testing::InMemoryStore; - use futures::StreamExt; - - /// Build a real `PersistedEnvelope` by round-tripping through - /// `InMemoryStore`: append a `PendingEnvelope`, then read it back. There is - /// no test-only `PendingEnvelope -> PersistedEnvelope` constructor in - /// `envelope.rs` (`PersistedEnvelope::for_decode` synthesizes an envelope - /// but carries no version, so it cannot exercise the `version` field this - /// module's tests need) — this mirrors the exact pattern used by - /// `crates/nexus-store/tests/subscription_tests.rs`. - async fn env(version: u64, meta: Option<&[u8]>) -> PersistedEnvelope { - let store = Store::new(InMemoryStore::new()); - let id = StreamKey::from_slice(b"stream-1"); - - // `append`'s `expected_version` is the CURRENT version (`None` = empty - // stream); to land an event at an arbitrary target `version` the - // stream must first be filled up to `version - 1`. - let mut expected = None; - for filler_version in 1..version { - let filler = pending_envelope(Version::new(filler_version).expect("nonzero version")) - .event_type("Filler") - .payload(b"filler".to_vec()) - .build() - .expect("valid envelope"); - store - .append(&id, expected, &[filler]) - .await - .expect("append succeeds"); - expected = Version::new(filler_version); - } - - let mut builder = pending_envelope(Version::new(version).expect("nonzero version")) - .event_type("E") - .payload(b"payload".to_vec()); - if let Some(m) = meta { - builder = builder.metadata(m.to_vec()); - } - let envelope = builder.build().expect("valid envelope"); - store - .append(&id, expected, &[envelope]) - .await - .expect("append succeeds"); - - let raw_stream = store - .read_stream(&id, Version::new(version).expect("nonzero version")) - .await - .expect("read_stream succeeds"); - let mut stream = std::pin::pin!(raw_stream); - stream - .next() - .await - .expect("at least one event") - .expect("read succeeds") - } - - #[tokio::test] - async fn retag_on_bare_envelope_is_identity_shape() { - let e = env(3, Some(b"m")).await; - let decoded = Decoded { - event: 42u64, - version: e.version(), - metadata: e.metadata_bytes(), - }; - let typed: Decoded = e.retag(decoded); - assert_eq!(typed.event, 42); - assert_eq!(typed.version, Version::new(3).expect("nonzero version")); - assert_eq!(typed.metadata.as_deref(), Some(b"m".as_ref())); - } - - #[tokio::test] - async fn retag_on_tagged_item_copies_the_position_beside_the_box() { - let e = env(1, None).await; - let item = (99u64, e); - let decoded = Decoded { - event: 7u64, - version: item.envelope().version(), - metadata: None, - }; - let (pos, typed): (u64, Decoded) = item.retag(decoded); - assert_eq!(pos, 99); - assert_eq!(typed.event, 7); - assert_eq!(typed.version, Version::new(1).expect("nonzero version")); - } - - #[test] - fn error_variants_render_distinct_messages() { - #[derive(Debug, thiserror::Error)] - #[error("boom")] - struct Boom; - let read: DecodeStreamError = DecodeStreamError::Read(Boom); - let decode: DecodeStreamError = DecodeStreamError::Decode(Boom); - assert_eq!(read.to_string(), "subscription stream read failed"); - assert_eq!(decode.to_string(), "event decode failed"); - } -} diff --git a/crates/nexus-store/src/export.rs b/crates/nexus-store/src/export.rs index 3b2c3342..edc32f25 100644 --- a/crates/nexus-store/src/export.rs +++ b/crates/nexus-store/src/export.rs @@ -125,432 +125,3 @@ impl StreamLister for Store { self.raw().list_streams().await } } - -#[cfg(test)] -#[allow( - clippy::unwrap_used, - clippy::expect_used, - reason = "test code asserts exact values" -)] -mod tests { - use super::*; - use crate::envelope::{PersistedEnvelope, pending_envelope}; - use crate::testing::{InMemoryStore, InMemoryStoreError}; - use futures::StreamExt; - use proptest::prelude::*; - use static_assertions::assert_impl_all; - use std::collections::HashSet; - use std::sync::Arc; - use tokio::sync::Barrier; - - // Every RawEventStore gains both capabilities — the blanket impl for - // EventExporter, the adapter impl for StreamLister. - assert_impl_all!(InMemoryStore: EventExporter, StreamLister, RawEventStore); - - // ── test stream key ─────────────────────────────────────────────────────── - - fn sk(s: &str) -> StreamKey { - StreamKey::from_slice(s.as_bytes()) - } - - fn env(v: u64, payload: &[u8]) -> crate::envelope::PendingEnvelope { - pending_envelope(Version::new(v).expect("nonzero")) - .event_type("E") - .payload(payload.to_vec()) - .build() - .expect("valid envelope") - } - - async fn append_one(store: &InMemoryStore, id: &StreamKey, v: u64, payload: &[u8]) { - let expected = Version::new(v - 1); - store - .append(id, expected, &[env(v, payload)]) - .await - .expect("append succeeds"); - } - - async fn seed(store: &InMemoryStore, id: &StreamKey, count: u64) { - for v in 1..=count { - append_one(store, id, v, format!("payload-{v}").as_bytes()).await; - } - } - - async fn collect_export( - store: &InMemoryStore, - id: &StreamKey, - from: Version, - ) -> Vec { - let stream = store.export_stream(id, from).await.expect("export opens"); - stream - .map(|r| r.expect("no read error")) - .collect::>() - .await - } - - // ════════════════════════════════════════════════════════════════════════ - // EventExporter — Category 1: sequence / protocol - // ════════════════════════════════════════════════════════════════════════ - - #[tokio::test] - async fn export_stream_is_byte_for_byte_the_read_stream() { - // The core contract: export is a verbatim pass-through of read_stream. - // Same versions, schema, event_type, payload, metadata (a per-stream - // event carries no global position). - let store = InMemoryStore::new(); - let id = sk("acct-1"); - seed(&store, &id, 5).await; - - let exported = collect_export(&store, &id, Version::INITIAL).await; - - let read: Vec = store - .read_stream(&id, Version::INITIAL) - .await - .expect("read opens") - .map(|r| r.expect("no read error")) - .collect() - .await; - - assert_eq!(exported.len(), read.len()); - for (e, r) in exported.iter().zip(read.iter()) { - assert_eq!(e.version(), r.version()); - assert_eq!(e.schema_version(), r.schema_version()); - assert_eq!(e.event_type(), r.event_type()); - assert_eq!(e.payload(), r.payload()); - assert_eq!(e.metadata(), r.metadata()); - } - } - - #[tokio::test] - async fn export_preserves_versions_and_payloads_in_order() { - let store = InMemoryStore::new(); - let id = sk("acct-1"); - seed(&store, &id, 5).await; - - let exported = collect_export(&store, &id, Version::INITIAL).await; - - let versions: Vec = exported.iter().map(|e| e.version().as_u64()).collect(); - assert_eq!(versions, vec![1, 2, 3, 4, 5]); - for (i, e) in exported.iter().enumerate() { - let expected = format!("payload-{}", i + 1); - assert_eq!(e.payload(), expected.as_bytes()); - } - } - - #[tokio::test] - async fn exporting_the_same_stream_twice_is_identical() { - let store = InMemoryStore::new(); - let id = sk("acct-1"); - seed(&store, &id, 4).await; - - let first = collect_export(&store, &id, Version::INITIAL).await; - let second = collect_export(&store, &id, Version::INITIAL).await; - - assert_eq!(first.len(), second.len()); - for (a, b) in first.iter().zip(second.iter()) { - assert_eq!(a.version(), b.version()); - assert_eq!(a.payload(), b.payload()); - } - } - - #[tokio::test] - async fn export_carries_metadata_and_schema_verbatim() { - let store = InMemoryStore::new(); - let id = sk("acct-1"); - let pending = pending_envelope(Version::INITIAL) - .event_type("Created") - .payload(b"body".to_vec()) - .schema_version(crate::value::SchemaVersion::from_u32(7).expect("nonzero")) - .metadata(b"meta".to_vec()) - .build() - .expect("valid envelope"); - store.append(&id, None, &[pending]).await.expect("append"); - - let exported = collect_export(&store, &id, Version::INITIAL).await; - - assert_eq!(exported.len(), 1); - let e = &exported[0]; - assert_eq!(e.event_type(), "Created"); - assert_eq!(e.payload(), b"body"); - assert_eq!(e.metadata(), Some(b"meta".as_slice())); - assert_eq!(e.schema_version(), 7); - } - - // ════════════════════════════════════════════════════════════════════════ - // EventExporter — Category 2: lifecycle - // ════════════════════════════════════════════════════════════════════════ - - #[tokio::test] - async fn export_empty_stream_terminates_and_does_not_hang() { - let store = InMemoryStore::new(); - let id = sk("never-appended"); - let mut stream = store - .export_stream(&id, Version::INITIAL) - .await - .expect("opens"); - // Must terminate (None), not block forever. - assert!(stream.next().await.is_none()); - // Stays terminated. - assert!(stream.next().await.is_none()); - } - - #[tokio::test] - async fn export_from_past_the_head_is_empty() { - let store = InMemoryStore::new(); - let id = sk("acct-1"); - seed(&store, &id, 3).await; - - let exported = collect_export(&store, &id, Version::new(10).expect("nonzero")).await; - assert!(exported.is_empty(), "from past head yields nothing"); - } - - #[tokio::test] - async fn export_from_midstream_is_inclusive_of_from() { - // Pins the resolved semantics: `from` is INCLUSIVE (matches - // read_stream). from=3 on a 5-event stream yields [3,4,5]. - let store = InMemoryStore::new(); - let id = sk("acct-1"); - seed(&store, &id, 5).await; - - let exported = collect_export(&store, &id, Version::new(3).expect("nonzero")).await; - let versions: Vec = exported.iter().map(|e| e.version().as_u64()).collect(); - assert_eq!(versions, vec![3, 4, 5]); - } - - #[tokio::test] - async fn export_single_event_stream_yields_one() { - let store = InMemoryStore::new(); - let id = sk("acct-1"); - seed(&store, &id, 1).await; - - let exported = collect_export(&store, &id, Version::INITIAL).await; - assert_eq!(exported.len(), 1); - assert_eq!(exported[0].version(), Version::INITIAL); - } - - // ════════════════════════════════════════════════════════════════════════ - // EventExporter — Category 3: defensive boundary - // ════════════════════════════════════════════════════════════════════════ - - #[tokio::test] - async fn export_unknown_stream_is_empty_not_error() { - let store = InMemoryStore::new(); - // Seed a different stream so the store is non-empty. - seed(&store, &sk("other"), 2).await; - - let exported = collect_export(&store, &sk("does-not-exist"), Version::INITIAL).await; - assert!(exported.is_empty()); - } - - #[tokio::test] - async fn export_handles_empty_and_unusual_stream_ids() { - let store = InMemoryStore::new(); - // Empty id and a long id are both structurally legal stream ids. - let empty = sk(""); - let long = sk(&"x".repeat(300)); - seed(&store, &empty, 2).await; - seed(&store, &long, 3).await; - - assert_eq!( - collect_export(&store, &empty, Version::INITIAL).await.len(), - 2 - ); - assert_eq!( - collect_export(&store, &long, Version::INITIAL).await.len(), - 3 - ); - } - - // ════════════════════════════════════════════════════════════════════════ - // EventExporter — Category 4: linearizability / isolation - // ════════════════════════════════════════════════════════════════════════ - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_append_and_export_sees_a_consistent_prefix() { - // A writer appends 1..=N on a stream while an exporter drains it. The - // exporter must see a contiguous prefix starting at v1, strictly - // increasing, each event well-formed (payload matches its version) — - // never a gap, never a torn event. - let store = Arc::new(InMemoryStore::new()); - let id = sk("race"); - let n = 200u64; - - // Seed v1 BEFORE the race. Without this, the exporter can open and drain - // the stream before the writer commits its first event — a valid but - // empty prefix — making the `!exported.is_empty()` check below flaky - // (it raced pass-on-one-trigger, fail-on-another on identical commits). - // With v1 already committed, the exporter is guaranteed a non-empty - // prefix, so that assertion is a real, falsifiable guarantee rather than - // a timing coin-flip. The writer races 2..=n. - append_one(&store, &id, 1, b"payload-1").await; - - let barrier = Arc::new(Barrier::new(2)); - - let writer_store = Arc::clone(&store); - let writer_id = id.clone(); - let writer_barrier = Arc::clone(&barrier); - let writer = tokio::spawn(async move { - writer_barrier.wait().await; - for v in 2..=n { - append_one( - &writer_store, - &writer_id, - v, - format!("payload-{v}").as_bytes(), - ) - .await; - } - }); - - barrier.wait().await; - let stream = store - .export_stream(&id, Version::INITIAL) - .await - .expect("opens"); - let exported: Vec = - stream.map(|r| r.expect("no read error")).collect().await; - - writer.await.expect("writer task"); - - // Contiguous prefix from v1, strictly increasing, payload matches. - for (expected, e) in (1u64..).zip(exported.iter()) { - assert_eq!( - e.version().as_u64(), - expected, - "exported versions must be a gapless prefix from 1", - ); - assert_eq!(e.payload(), format!("payload-{expected}").as_bytes()); - } - // We may not have raced the full N in, but every event we saw is a - // valid, ordered prefix — and v1 was seeded before the race, so the - // exporter must have seen at least it. - assert!( - !exported.is_empty(), - "exporter must see at least the seeded v1" - ); - } - - // ════════════════════════════════════════════════════════════════════════ - // StreamLister - // ════════════════════════════════════════════════════════════════════════ - - async fn collect_stream_ids(store: &InMemoryStore) -> HashSet> { - let stream = store.list_streams().await.expect("list opens"); - stream - .map(|r| r.expect("no list error").as_bytes().to_vec()) - .collect::>() - .await - .into_iter() - .collect() - } - - #[tokio::test] - async fn list_streams_returns_exactly_the_appended_ids() { - let store = InMemoryStore::new(); - seed(&store, &sk("alpha"), 1).await; - seed(&store, &sk("beta"), 2).await; - seed(&store, &sk("gamma"), 3).await; - - let ids = collect_stream_ids(&store).await; - let expected: HashSet> = [b"alpha".to_vec(), b"beta".to_vec(), b"gamma".to_vec()] - .into_iter() - .collect(); - assert_eq!(ids, expected); - } - - #[tokio::test] - async fn list_streams_on_empty_store_is_empty() { - let store = InMemoryStore::new(); - let ids = collect_stream_ids(&store).await; - assert!(ids.is_empty()); - } - - #[tokio::test] - async fn list_streams_lists_each_stream_once_regardless_of_event_count() { - let store = InMemoryStore::new(); - seed(&store, &sk("busy"), 50).await; - - let stream = store.list_streams().await.expect("opens"); - let all: Vec> = stream - .map(|r| r.expect("no error").as_bytes().to_vec()) - .collect::>() - .await; - assert_eq!(all, vec![b"busy".to_vec()], "one entry, no per-event dupes"); - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(32))] - #[test] - fn list_streams_matches_a_hashset_oracle( - ids in proptest::collection::hash_set("[a-z]{1,12}", 0..20), - ) { - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .expect("runtime"); - rt.block_on(async { - let store = InMemoryStore::new(); - for s in &ids { - append_one(&store, &sk(s), 1, b"p").await; - } - let listed = collect_stream_ids(&store).await; - let oracle: HashSet> = - ids.iter().map(|s| s.as_bytes().to_vec()).collect(); - prop_assert_eq!(listed, oracle); - Ok(()) - })?; - } - } - - // Surfaces a typed error from a successful no-event read path — pins that - // the StreamList item error type is the adapter error (not boxed). - #[tokio::test] - async fn stream_list_error_type_is_the_adapter_error() { - fn assert_err_type>(_: &S) {} - let store = InMemoryStore::new(); - assert_err_type(&store); - } - - // ── #247: the Store handle is the front door — list/export, no .raw() ──── - - #[tokio::test] - async fn store_handle_lists_and_exports_without_raw() { - // Substitutable: generic `StreamLister`/`EventExporter`-bounded code - // accepts the handle directly (the structural win of #247). - fn assert_lister(_: &L) {} - fn assert_exporter(_: &E) {} - - // The handle itself appends, lists, and exports — never `.raw()`. - let store = Store::new(InMemoryStore::new()); - store - .append(&sk("acct-1"), None, &[env(1, b"x")]) - .await - .expect("append via the handle"); - - let ids: HashSet> = store - .list_streams() - .await - .expect("list via the handle") - .map(|r| r.expect("no list error").as_bytes().to_vec()) - .collect::>() - .await - .into_iter() - .collect(); - assert_eq!( - ids, - std::iter::once(b"acct-1".to_vec()).collect::>() - ); - - let exported: Vec = store - .export_stream(&sk("acct-1"), Version::INITIAL) - .await - .expect("export via the handle") - .map(|r| r.expect("no read error")) - .collect() - .await; - assert_eq!(exported.len(), 1); - assert_eq!(exported[0].version(), Version::INITIAL); - - // The generic bounds above accept the handle directly (the #247 win). - assert_lister(&store); - assert_exporter(&store); - } -} diff --git a/crates/nexus-store/src/import.rs b/crates/nexus-store/src/import.rs index e84195fc..b688668f 100644 --- a/crates/nexus-store/src/import.rs +++ b/crates/nexus-store/src/import.rs @@ -599,330 +599,16 @@ where clippy::panic, reason = "test code asserts exact values" )] -mod tests { +mod plan_tests { use super::*; - use crate::envelope::{PendingEnvelope, pending_envelope}; - use crate::export::EventExporter; + use crate::envelope::PersistedEnvelope; use crate::value::SchemaVersion; use bytes::Bytes; - use futures::StreamExt; - use proptest::prelude::*; - use static_assertions::assert_impl_all; - use std::error::Error as _; - use std::sync::Arc; - use tokio::sync::Barrier; fn v(n: u64) -> Version { Version::new(n).expect("test version must be nonzero") } - fn report(stream: &str, outcome: StreamOutcome) -> StreamReport { - StreamReport { - stream: StreamKey::from_slice(stream.as_bytes()), - outcome, - } - } - - // ── Send + Sync: report/outcome/error types travel between tasks ───────── - - assert_impl_all!(StreamOutcome: Send, Sync, Copy, Clone); - assert_impl_all!(Atomicity: Send, Sync, Copy, Clone); - assert_impl_all!(AbortReason: Send, Sync, Clone); - assert_impl_all!(StreamReport: Send, Sync, Clone); - assert_impl_all!(ImportReport: Send, Sync, Clone); - assert_impl_all!(ImportError: Send, Sync); - - // ── Atomicity: equality + Copy semantics ──────────────────────────────── - - #[test] - fn atomicity_equality_and_copy_semantics() { - let policy = Atomicity::WholeChunk; - let copied = policy; // `Copy`: `policy` is still usable below. - assert_eq!(policy, copied); - assert_eq!(Atomicity::WholeChunk, Atomicity::WholeChunk); - assert_eq!(Atomicity::PerStream, Atomicity::PerStream); - assert_ne!(Atomicity::WholeChunk, Atomicity::PerStream); - } - - // ── StreamOutcome::is_complete / reached — all variants ───────────────── - - #[test] - fn complete_is_complete_and_reaches_its_version() { - let outcome = StreamOutcome::Complete { version: v(5) }; - assert!(outcome.is_complete()); - assert_eq!(outcome.reached(), Some(v(5))); - } - - #[test] - fn corrupt_untouched_is_not_complete_and_reaches_none() { - let outcome = StreamOutcome::Corrupt { reached: None }; - assert!(!outcome.is_complete()); - assert_eq!(outcome.reached(), None); - } - - #[test] - fn corrupt_with_prefix_reaches_that_prefix() { - let outcome = StreamOutcome::Corrupt { - reached: Some(v(3)), - }; - assert!(!outcome.is_complete()); - assert_eq!(outcome.reached(), Some(v(3))); - } - - #[test] - fn mismatch_untouched_reaches_none_not_got() { - // reached and got are distinct values: a bug returning `got` would - // make this fail. - let outcome = StreamOutcome::Mismatch { - reached: None, - got: v(8), - }; - assert!(!outcome.is_complete()); - assert_eq!(outcome.reached(), None); - } - - #[test] - fn mismatch_with_prefix_reaches_prefix_not_got() { - let outcome = StreamOutcome::Mismatch { - reached: Some(v(3)), - got: v(8), - }; - assert!(!outcome.is_complete()); - // Must be the prefix (3), never the offending version (8). - assert_eq!(outcome.reached(), Some(v(3))); - } - - // ── ImportReport / StreamReport: new / streams / unfinished / all_complete - - #[test] - fn empty_report_is_vacuously_all_complete_with_no_unfinished() { - let report: ImportReport = ImportReport::new(Vec::new()); - assert!(report.streams().is_empty()); - assert!(report.all_complete()); - assert_eq!(report.unfinished().count(), 0); - } - - #[test] - fn all_complete_report_reports_no_unfinished() { - let streams = vec![ - report("a", StreamOutcome::Complete { version: v(1) }), - report("b", StreamOutcome::Complete { version: v(2) }), - ]; - let report = ImportReport::new(streams.clone()); - assert_eq!(report.streams(), streams.as_slice()); - assert!(report.all_complete()); - assert_eq!(report.unfinished().count(), 0); - } - - #[test] - fn mixed_report_filters_exactly_the_non_complete_streams() { - let streams = vec![ - report("done-1", StreamOutcome::Complete { version: v(1) }), - report( - "corrupt", - StreamOutcome::Corrupt { - reached: Some(v(2)), - }, - ), - report("done-2", StreamOutcome::Complete { version: v(3) }), - report( - "mismatch", - StreamOutcome::Mismatch { - reached: None, - got: v(9), - }, - ), - ]; - let report = ImportReport::new(streams); - - // streams() preserves first-seen order and full set. - let all_ids: Vec<&[u8]> = report - .streams() - .iter() - .map(|s| s.stream.as_bytes()) - .collect(); - assert_eq!( - all_ids, - [ - b"done-1".as_ref(), - b"corrupt".as_ref(), - b"done-2".as_ref(), - b"mismatch".as_ref() - ] - ); - - // unfinished() is EXACTLY the two non-Complete streams, in order. - let unfinished_ids: Vec<&[u8]> = report.unfinished().map(|s| s.stream.as_bytes()).collect(); - assert_eq!(unfinished_ids, [b"corrupt".as_ref(), b"mismatch".as_ref()]); - - assert!(!report.all_complete()); - } - - #[test] - fn single_incomplete_stream_blocks_all_complete() { - let report = ImportReport::new(vec![report( - "only", - StreamOutcome::Corrupt { reached: None }, - )]); - assert!(!report.all_complete()); - assert_eq!(report.unfinished().count(), 1); - } - - // ── AbortReason / ImportError Display + source ────────────────────────── - - #[test] - fn abort_reason_display_strings_are_exact() { - assert_eq!(AbortReason::Corrupt.to_string(), "block failed checksum"); - assert_eq!( - AbortReason::Mismatch { - expected: v(4), - got: v(7), - } - .to_string(), - "version mismatch (expected 4, got 7)", - ); - } - - #[test] - fn import_error_malformed_display_is_exact() { - let err: ImportError = ImportError::Malformed("bad magic"); - assert_eq!(err.to_string(), "malformed chunk: bad magic"); - } - - #[test] - fn import_error_aborted_display_formats_stream_and_reason() { - let err: ImportError = ImportError::Aborted { - stream: StreamKey::from_slice(b"phone:task-123"), - reason: AbortReason::Mismatch { - expected: v(2), - got: v(5), - }, - }; - // {stream} → StreamKey's Display (the UTF-8 string, unquoted); reason - // via its own Display. - assert_eq!( - err.to_string(), - "chunk aborted at stream phone:task-123: version mismatch (expected 2, got 5)", - ); - } - - #[test] - fn import_error_version_overflow_display_is_exact() { - let err: ImportError = ImportError::VersionOverflow; - assert_eq!(err.to_string(), "version overflow"); - } - - #[test] - fn import_error_store_is_transparent_forwarding_display_and_source() { - // A store error WITH its own source, to prove transparent forwarding - // reaches through to the inner error's source (not just Display). - #[derive(Debug, Error)] - #[error("root cause")] - struct RootCause; - - #[derive(Debug, Error)] - #[error("store failed")] - struct DummyStore(#[source] RootCause); - - let err: ImportError = ImportError::Store(DummyStore(RootCause)); - - // transparent → Display equals the inner store error's Display. - assert_eq!(err.to_string(), "store failed"); - // transparent → source() forwards to the inner error's own source. - let source = err - .source() - .expect("transparent Store must forward a source"); - assert_eq!(source.to_string(), "root cause"); - } - - #[test] - fn non_store_variants_expose_no_error_source() { - // Error-chain shape pin: `Store` is the ONLY source-forwarding variant. - // `Aborted` deliberately interpolates its `reason` into the message - // (not `#[source]`), so it terminates the chain too. - let malformed: ImportError = ImportError::Malformed("x"); - assert!(malformed.source().is_none()); - - let overflow: ImportError = ImportError::VersionOverflow; - assert!(overflow.source().is_none()); - - let aborted: ImportError = ImportError::Aborted { - stream: StreamKey::from_slice(b"s"), - reason: AbortReason::Corrupt, - }; - assert!(aborted.source().is_none()); - } - - // ── AtomicAppend helpers and tests ─────────────────────────────────────── - - fn sk(s: &str) -> StreamKey { - StreamKey::from_slice(s.as_bytes()) - } - - fn pending(ver: u64, payload: &[u8]) -> PendingEnvelope { - pending_envelope(v(ver)) - .event_type("E") - .payload(Bytes::copy_from_slice(payload)) - .build() - .expect("valid envelope") - } - - fn planned(target: &str, expected: Option, versions: &[u64]) -> PlannedAppend { - PlannedAppend { - target: sk(target), - expected_version: expected.and_then(Version::new), - events: versions.iter().map(|n| pending(*n, b"p")).collect(), - } - } - - async fn head_len(store: &crate::testing::InMemoryStore, id: &StreamKey) -> usize { - store - .read_stream(id, Version::INITIAL) - .await - .expect("read opens") - .filter_map(|r| async move { r.ok() }) - .count() - .await - } - - #[tokio::test] - async fn atomic_append_many_commits_all_writes() { - let store = crate::testing::InMemoryStore::new(); - let writes = vec![planned("a", None, &[1, 2]), planned("b", None, &[1])]; - store.atomic_append_many(&writes).await.expect("commits"); - assert_eq!(head_len(&store, &sk("a")).await, 2); - assert_eq!(head_len(&store, &sk("b")).await, 1); - } - - #[tokio::test] - async fn atomic_append_many_rolls_back_all_on_one_conflict() { - let store = crate::testing::InMemoryStore::new(); - // Pre-seed "b" to v1 so the second write (expecting fresh) conflicts. - store - .append(&sk("b"), None, &[pending(1, b"seed")]) - .await - .expect("seed"); - - let writes = vec![ - planned("a", None, &[1, 2]), // would be fine alone - planned("b", None, &[1]), // conflicts: "b" is already at v1 - ]; - let err = store - .atomic_append_many(&writes) - .await - .expect_err("must conflict"); - match err { - AtomicAppendError::Conflict { index, actual } => { - assert_eq!(index, 1); - assert_eq!(actual, Version::new(1)); - } - AtomicAppendError::Store(_) => panic!("expected Conflict, got Store"), - } - // Atomicity: NOTHING landed — "a" must still be empty. - assert_eq!(head_len(&store, &sk("a")).await, 0, "rolled back"); - assert_eq!(head_len(&store, &sk("b")).await, 1, "unchanged"); - } - // ── per-section planner ────────────────────────────────────────────────── fn persisted(version: u64, payload: &[u8]) -> PersistedEnvelope { @@ -1073,743 +759,4 @@ mod tests { } // ── EventImporter PerStream behavioral tests ───────────────────────────── - - fn identity_route(origin: &[u8]) -> StreamKey { - StreamKey::from_slice(origin) - } - - async fn versions(store: &crate::testing::InMemoryStore, id: &StreamKey) -> Vec { - store - .read_stream(id, Version::INITIAL) - .await - .expect("read opens") - .map(|r| r.expect("no read error").version().as_u64()) - .collect() - .await - } - - #[tokio::test] - async fn per_stream_clean_multi_stream_all_complete() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![ - section("a", vec![evt(1), evt(2), evt(3)]), - section("b", vec![evt(1), evt(2)]), - ]; - let report = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - - assert!(report.all_complete()); - assert_eq!(report.streams().len(), 2); - assert_eq!(versions(&store, &sk("a")).await, vec![1, 2, 3]); - assert_eq!(versions(&store, &sk("b")).await, vec![1, 2]); - assert_eq!( - report.streams()[0].outcome, - StreamOutcome::Complete { version: v(3) } - ); - } - - #[tokio::test] - async fn per_stream_partial_overlap_is_picky_rejected() { - let store = crate::testing::InMemoryStore::new(); - for n in 1..=3 { - store - .append(&sk("a"), Version::new(n - 1), &[pending(n, b"seed")]) - .await - .expect("seed"); - } - let sections = vec![section("a", vec![evt(2), evt(3), evt(4), evt(5)])]; - let report = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - - assert_eq!( - report.streams()[0].outcome, - StreamOutcome::Mismatch { - reached: None, - got: v(2) - } - ); - assert_eq!(versions(&store, &sk("a")).await, vec![1, 2, 3]); - } - - #[tokio::test] - async fn per_stream_internal_gap_applies_prefix_then_halts() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section("a", vec![evt(1), evt(2), evt(4)])]; - let report = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - - assert_eq!( - report.streams()[0].outcome, - StreamOutcome::Mismatch { - reached: Some(v(2)), - got: v(4) - } - ); - assert_eq!(versions(&store, &sk("a")).await, vec![1, 2], "v4 held back"); - } - - #[tokio::test] - async fn per_stream_mid_section_corrupt_applies_prefix_then_corrupt() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section( - "a", - vec![evt(1), evt(2), ImportBlock::Corrupt, evt(3)], - )]; - let report = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - - assert_eq!( - report.streams()[0].outcome, - StreamOutcome::Corrupt { - reached: Some(v(2)) - } - ); - assert_eq!(versions(&store, &sk("a")).await, vec![1, 2]); - } - - #[tokio::test] - async fn per_stream_first_block_corrupt_reaches_none() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section("a", vec![ImportBlock::Corrupt, evt(1)])]; - let report = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - assert_eq!( - report.streams()[0].outcome, - StreamOutcome::Corrupt { reached: None } - ); - assert_eq!(versions(&store, &sk("a")).await, Vec::::new()); - } - - #[tokio::test] - async fn per_stream_empty_chunk_is_empty_report() { - let store = crate::testing::InMemoryStore::new(); - let report = store - .import(&[], identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - assert!(report.streams().is_empty()); - assert!(report.all_complete()); - } - - #[tokio::test] - async fn per_stream_version_overflow_surfaces_error() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section("a", vec![evt(u64::MAX), evt(1)])]; - let err = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect_err("overflow"); - assert!(matches!(err, ImportError::VersionOverflow)); - } - - // ── FailingAppendStore — test-only store for exercising Store-error arm ── - - /// Test-only store that delegates to `InMemoryStore` but fails `append` - /// (with a Store error) for one target stream — the only way to exercise - /// `import_per_stream`'s `AppendError::Store` arm, which `InMemoryStore` - /// alone cannot produce. - struct FailingAppendStore { - inner: crate::testing::InMemoryStore, - fail_on: String, - } - - impl crate::store::RawEventStore for FailingAppendStore { - type Error = crate::testing::InMemoryStoreError; - type Stream = crate::testing::InMemoryStream; - type AllPosition = crate::testing::InMemoryAllPos; - type AllStream = crate::testing::InMemoryAllStream; - - async fn append( - &self, - id: &StreamKey, - expected_version: Option, - envelopes: &[crate::envelope::PendingEnvelope], - ) -> Result<(), AppendError> { - if id.to_string() == self.fail_on { - return Err(AppendError::Store( - crate::testing::InMemoryStoreError::VersionOverflow, - )); - } - self.inner.append(id, expected_version, envelopes).await - } - - async fn read_stream( - &self, - id: &StreamKey, - from: Version, - ) -> Result { - self.inner.read_stream(id, from).await - } - - async fn read_all( - &self, - from: Option, - ) -> Result { - self.inner.read_all(from).await - } - } - - impl crate::import::AtomicAppend for FailingAppendStore { - async fn atomic_append_many( - &self, - writes: &[crate::import::PlannedAppend], - ) -> Result<(), crate::import::AtomicAppendError> { - self.inner.atomic_append_many(writes).await - } - } - - #[tokio::test] - async fn per_stream_store_error_propagates_and_keeps_prior_commits() { - let store = FailingAppendStore { - inner: crate::testing::InMemoryStore::new(), - fail_on: "b".to_owned(), - }; - let sections = vec![ - section("a", vec![evt(1), evt(2)]), // commits - section("b", vec![evt(1)]), // append fails with Store - ]; - let err = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect_err("store error must surface"); - assert!(matches!(err, ImportError::Store(_))); - // No cross-stream rollback: section "a" stayed committed. - assert_eq!(versions(&store.inner, &sk("a")).await, vec![1, 2]); - } - - // ── EventImporter WholeChunk behavioral tests ──────────────────────────── - - #[tokio::test] - async fn whole_chunk_clean_commits_all_complete() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![ - section("a", vec![evt(1), evt(2)]), - section("b", vec![evt(1)]), - ]; - let report = store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect("import ok"); - assert!(report.all_complete()); - assert_eq!(versions(&store, &sk("a")).await, vec![1, 2]); - assert_eq!(versions(&store, &sk("b")).await, vec![1]); - } - - #[tokio::test] - async fn whole_chunk_corrupt_block_aborts_nothing_lands() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![ - section("a", vec![evt(1), evt(2)]), // would be fine - section("b", vec![evt(1), ImportBlock::Corrupt]), // bad block - ]; - let err = store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect_err("aborts"); - match err { - ImportError::Aborted { stream, reason } => { - assert_eq!(stream, sk("b")); - assert_eq!(reason, AbortReason::Corrupt); - } - other => panic!("expected Aborted, got {other:?}"), - } - // Atomicity: NOTHING landed — even the clean section "a" must be empty. - assert_eq!(versions(&store, &sk("a")).await, Vec::::new()); - assert_eq!(versions(&store, &sk("b")).await, Vec::::new()); - } - - #[tokio::test] - async fn whole_chunk_internal_gap_aborts_mismatch() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section("a", vec![evt(1), evt(2), evt(4)])]; - let err = store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect_err("aborts"); - match err { - ImportError::Aborted { stream, reason } => { - assert_eq!(stream, sk("a")); - assert_eq!( - reason, - AbortReason::Mismatch { - expected: v(3), - got: v(4) - } - ); - } - other => panic!("expected Aborted, got {other:?}"), - } - assert_eq!(versions(&store, &sk("a")).await, Vec::::new()); - } - - #[tokio::test] - async fn whole_chunk_head_conflict_aborts_mismatch() { - let store = crate::testing::InMemoryStore::new(); - for n in 1..=2 { - store - .append(&sk("a"), Version::new(n - 1), &[pending(n, b"seed")]) - .await - .expect("seed"); - } - let sections = vec![section("a", vec![evt(1), evt(2)])]; - let err = store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect_err("aborts"); - match err { - ImportError::Aborted { stream, reason } => { - assert_eq!(stream, sk("a")); - // store head 2 → wanted v3 next; offered v1. - assert_eq!( - reason, - AbortReason::Mismatch { - expected: v(3), - got: v(1) - } - ); - } - other => panic!("expected Aborted, got {other:?}"), - } - assert_eq!(versions(&store, &sk("a")).await, vec![1, 2], "unchanged"); - } - - #[tokio::test] - async fn whole_chunk_first_block_corrupt_aborts() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section("a", vec![ImportBlock::Corrupt])]; - let err = store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect_err("aborts"); - assert!(matches!( - err, - ImportError::Aborted { ref stream, reason: AbortReason::Corrupt } if *stream == sk("a") - )); - } - - #[tokio::test] - async fn whole_chunk_conflict_on_later_section_reports_that_section() { - let store = crate::testing::InMemoryStore::new(); - // Pre-seed "b" to v1 so the SECOND write conflicts (index 1), while "a" - // (index 0) would be fine — exercises index > 0 in the Conflict arm. - store - .append(&sk("b"), None, &[pending(1, b"seed")]) - .await - .expect("seed"); - let sections = vec![ - section("a", vec![evt(1), evt(2)]), - section("b", vec![evt(1)]), - ]; - let err = store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect_err("aborts"); - match err { - ImportError::Aborted { stream, reason } => { - assert_eq!( - stream, - sk("b"), - "must report the conflicting SECOND section" - ); - // "b" head is 1 → wanted v2 next; offered v1. - assert_eq!( - reason, - AbortReason::Mismatch { - expected: v(2), - got: v(1) - } - ); - } - other => panic!("expected Aborted, got {other:?}"), - } - // All-or-nothing: the clean section "a" must NOT have landed. - assert_eq!(versions(&store, &sk("a")).await, Vec::::new()); - assert_eq!(versions(&store, &sk("b")).await, vec![1], "unchanged"); - } - - #[tokio::test] - async fn whole_chunk_empty_section_skipped_others_commit() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section("empty", vec![]), section("b", vec![evt(1), evt(2)])]; - let report = store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect("import ok"); - assert!(report.all_complete()); - // The empty section produces NO report entry; only "b". - assert_eq!(report.streams().len(), 1); - assert_eq!(report.streams()[0].stream, sk("b")); - assert_eq!(versions(&store, &sk("b")).await, vec![1, 2]); - } - - // ── Defensive boundary: non-injective route (two origins → one target) ──── - - #[tokio::test] - async fn whole_chunk_non_injective_route_aborts_no_corruption() { - // A non-injective route maps BOTH origin sections to the same target. - // WholeChunk must abort with nothing landed — never a [1,2,1,2] stream. - let store = crate::testing::InMemoryStore::new(); - let sections = vec![ - section("o1", vec![evt(1), evt(2)]), - section("o2", vec![evt(1), evt(2)]), - ]; - let to_same = |_origin: &[u8]| sk("T"); - let err = store - .import(§ions, to_same, Atomicity::WholeChunk) - .await - .expect_err("non-injective route must abort"); - assert!(matches!(err, ImportError::Aborted { .. })); - // All-or-nothing + no corruption: T is empty, definitely not [1,2,1,2]. - assert_eq!(versions(&store, &sk("T")).await, Vec::::new()); - } - - #[tokio::test] - async fn per_stream_non_injective_route_second_rejected_no_corruption() { - // PerStream: the first section commits the target; the second (same - // target) is picky-rejected — the stream is [1,2], never [1,2,1,2]. - let store = crate::testing::InMemoryStore::new(); - let sections = vec![ - section("o1", vec![evt(1), evt(2)]), - section("o2", vec![evt(1), evt(2)]), - ]; - let to_same = |_origin: &[u8]| sk("T"); - let report = store - .import(§ions, to_same, Atomicity::PerStream) - .await - .expect("import ok"); - assert_eq!( - versions(&store, &sk("T")).await, - vec![1, 2], - "no [1,2,1,2] corruption — second section rejected, not appended" - ); - assert_eq!( - report.streams()[0].outcome, - StreamOutcome::Complete { version: v(2) } - ); - assert_eq!( - report.streams()[1].outcome, - StreamOutcome::Mismatch { - reached: None, - got: v(1) - } - ); - } - - // ── #247: the Store handle is the front door — import, no .raw() ───────── - - #[tokio::test] - async fn store_handle_imports_without_raw() { - // Substitutable: generic `EventImporter`/`AtomicAppend`-bounded code - // accepts the handle directly (the structural win of #247). - fn assert_importer(_: &I) {} - fn assert_atomic(_: &A) {} - - // The handle itself imports and atomic-appends — never `.raw()`. - let store = Store::new(crate::testing::InMemoryStore::new()); - let sections = vec![section("a", vec![evt(1), evt(2)])]; - let report = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import via the handle"); - assert!(report.all_complete()); - assert_eq!( - report.streams()[0].outcome, - StreamOutcome::Complete { version: v(2) } - ); - store - .atomic_append_many(&[planned("b", None, &[1])]) - .await - .expect("atomic append via the handle"); - - // The generic bounds above accept the handle directly (the #247 win). - assert_importer(&store); - assert_atomic(&store); - } - - // ── Round-trip + idempotency (Card-1 export ↔ Card-2 import) ──────────── - - async fn export_section(store: &crate::testing::InMemoryStore, origin: &str) -> StreamSection { - let blocks = store - .export_stream(&sk(origin), Version::INITIAL) - .await - .expect("export opens") - .map(|r| ImportBlock::Event(r.expect("no read error"))) - .collect::>() - .await; - section(origin, blocks) - } - - #[tokio::test] - async fn export_then_import_round_trips_byte_equal_modulo_all_position() { - // Source store with two streams. - let source = crate::testing::InMemoryStore::new(); - for n in 1..=3 { - source - .append(&sk("acct-1"), Version::new(n - 1), &[pending(n, b"a")]) - .await - .expect("seed a"); - } - source - .append(&sk("acct-2"), None, &[pending(1, b"b")]) - .await - .expect("seed b"); - - let sections = vec![ - export_section(&source, "acct-1").await, - export_section(&source, "acct-2").await, - ]; - - // Import into a FRESH store, identity routing. - let target = crate::testing::InMemoryStore::new(); - // Pre-seed a warmup event so the target's `$all` counter is already - // advanced — imported events must get FRESH positions (2..) rather than - // copying the source's (which started at 1), making the restamp - // observable on the `$all` read. - target - .append(&sk("warmup"), None, &[pending(1, b"warmup")]) - .await - .expect("warmup seed"); - let report = target - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - assert!(report.all_complete()); - - // Per-stream events are byte-equal (a per-stream event carries no global - // position): compare version/type/payload/metadata/schema verbatim. - for origin in ["acct-1", "acct-2"] { - let src: Vec = source - .read_stream(&sk(origin), Version::INITIAL) - .await - .expect("read") - .map(|r| r.expect("ok")) - .collect() - .await; - let dst: Vec = target - .read_stream(&sk(origin), Version::INITIAL) - .await - .expect("read") - .map(|r| r.expect("ok")) - .collect() - .await; - assert_eq!(src.len(), dst.len(), "{origin} length"); - for (s, d) in src.iter().zip(dst.iter()) { - assert_eq!(s.version(), d.version(), "{origin} version"); - assert_eq!(s.event_type(), d.event_type(), "{origin} type"); - assert_eq!(s.payload(), d.payload(), "{origin} payload"); - assert_eq!(s.metadata(), d.metadata(), "{origin} metadata"); - assert_eq!(s.schema_version(), d.schema_version(), "{origin} schema"); - } - } - - // Restamp observable on `$all`: the target re-stamped fresh positions, so - // its `$all` order is the warmup (1) then the imported events (2..=5), - // strictly increasing — NOT the source's positions copied verbatim. - let target_positions: Vec = target - .read_all(None) - .await - .expect("read_all") - .map(|r| r.expect("ok").0.as_u64()) - .collect() - .await; - assert_eq!( - target_positions, - vec![1, 2, 3, 4, 5], - "import must re-stamp fresh $all positions, not copy the source's", - ); - } - - #[tokio::test] - async fn reimport_same_chunk_is_idempotent_per_stream() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section("a", vec![evt(1), evt(2), evt(3)])]; - - let first = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - assert!(first.all_complete()); - - let second = store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok"); - assert_eq!( - second.streams()[0].outcome, - StreamOutcome::Mismatch { - reached: None, - got: v(1) - } - ); - assert_eq!(versions(&store, &sk("a")).await, vec![1, 2, 3], "no dupes"); - } - - #[tokio::test] - async fn reimport_same_chunk_whole_chunk_aborts() { - let store = crate::testing::InMemoryStore::new(); - let sections = vec![section("a", vec![evt(1), evt(2)])]; - store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect("first import ok"); - let err = store - .import(§ions, identity_route, Atomicity::WholeChunk) - .await - .expect_err("re-import aborts"); - match err { - ImportError::Aborted { stream, reason } => { - assert_eq!(stream, sk("a")); - assert_eq!( - reason, - AbortReason::Mismatch { - expected: v(3), - got: v(1) - } - ); - } - other => panic!("expected Aborted, got {other:?}"), - } - assert_eq!(versions(&store, &sk("a")).await, vec![1, 2], "no dupes"); - } - - // ── Linearizability: concurrent import vs direct writer (CLAUDE rule 7 §4) ─ - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn per_stream_import_concurrent_with_writer_surfaces_conflict_no_torn_stream() { - // Two actors race to populate the SAME fresh target stream from v1: - // a direct writer (append v1..=N) and an importer (a v1..=N section). - // Exactly one wins the v1 slot; the other must surface a Mismatch (a - // conflict, never a silent retry — CLAUDE rule 5). The stream must end - // as a gapless prefix from v1, never torn. - let store = Arc::new(crate::testing::InMemoryStore::new()); - let n = 50u64; - let barrier = Arc::new(Barrier::new(2)); - - let writer_store = Arc::clone(&store); - let writer_barrier = Arc::clone(&barrier); - let writer = tokio::spawn(async move { - writer_barrier.wait().await; - for vn in 1..=n { - // Conflicts are expected once the importer wins; ignore them. - let _ = writer_store - .append(&sk("race"), Version::new(vn - 1), &[pending(vn, b"w")]) - .await; - } - }); - - let importer_store = Arc::clone(&store); - let importer_barrier = Arc::clone(&barrier); - let importer = tokio::spawn(async move { - importer_barrier.wait().await; - let blocks: Vec = (1..=n).map(evt).collect(); - let sections = vec![section("race", blocks)]; - importer_store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import returns Ok (conflicts are per-stream outcomes)") - }); - - writer.await.expect("writer task"); - let report = importer.await.expect("importer task"); - - // The importer's outcome is either Complete (it won v1) or Mismatch - // (the writer won) — never a partial/torn application. - let outcome = report.streams().first().map(|s| s.outcome); - match outcome { - Some( - StreamOutcome::Complete { .. } | StreamOutcome::Mismatch { reached: None, .. }, - ) => {} - other => panic!("unexpected importer outcome: {other:?}"), - } - - // Whatever the interleaving, the final stream is a gapless prefix from 1. - let final_versions = versions(&store, &sk("race")).await; - for (expected, got) in (1u64..).zip(final_versions.iter()) { - assert_eq!(*got, expected, "stream must be a gapless prefix from 1"); - } - assert!(!final_versions.is_empty(), "some events landed"); - } - - // ── State-machine property test: PerStream import never gaps ──────────── - - // Model-based: a sequence of single-stream PerStream imports against one - // target. The model tracks the stream's next-expected version; each import - // offers a first version + a contiguous block count. Invariants: the - // reported outcome matches the model's picky rule, the stream never develops - // a gap, and what landed matches the report. - #[derive(Debug, Clone)] - enum Cmd { - // Offer `count` contiguous events starting at `first`. - Import { first: u64, count: u64 }, - } - - fn cmd_strategy() -> impl Strategy { - // Boundary-inclusive: first ∈ {1,2,3}, count ∈ {1,2,3}. - (prop_oneof![Just(1u64), Just(2u64), Just(3u64)], 1u64..=3) - .prop_map(|(first, count)| Cmd::Import { first, count }) - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] - #[test] - fn state_machine_per_stream_never_gaps_and_report_matches_model( - cmds in proptest::collection::vec(cmd_strategy(), 1..12), - ) { - let rt = tokio::runtime::Builder::new_current_thread() - .build() - .expect("runtime"); - rt.block_on(async { - let store = crate::testing::InMemoryStore::new(); - let id = sk("m"); - // Model: next expected version (1 = fresh). - let mut next: u64 = 1; - - for cmd in cmds { - let Cmd::Import { first, count } = cmd; - let blocks: Vec = - (first..first + count).map(evt).collect(); - let sections = vec![section("m", blocks)]; - let report = { - store - .import(§ions, identity_route, Atomicity::PerStream) - .await - .expect("import ok") - }; - let outcome = report.streams()[0].outcome; - - if first == next { - // Picky check passes: the whole contiguous run applies. - let landed_last = first + count - 1; - prop_assert_eq!( - outcome, - StreamOutcome::Complete { version: v(landed_last) } - ); - next = landed_last + 1; - } else { - // Picky reject: nothing applied, model unchanged. - prop_assert_eq!( - outcome, - StreamOutcome::Mismatch { reached: None, got: v(first) } - ); - } - - // Invariant: the stream is ALWAYS a gapless prefix 1..next-1. - let got = versions(&store, &id).await; - let expected: Vec = (1..next).collect(); - prop_assert_eq!(got, expected); - } - Ok(()) - })?; - } - } } diff --git a/crates/nexus-store/src/lib.rs b/crates/nexus-store/src/lib.rs index b5927c0a..382b5084 100644 --- a/crates/nexus-store/src/lib.rs +++ b/crates/nexus-store/src/lib.rs @@ -72,8 +72,7 @@ //! | `snapshot-json` | `snapshot` + `json` | //! | `projection` | `Projector` trait | //! | `projection-json` | `projection` + `json` | -//! | `subscription` | [`StreamNotifiers`](crate::notify) per-stream wake registry (pulls `tokio`, `foldhash`, `parking_lot`) | -//! | `testing` | `InMemoryStore`, `InMemorySnapshotStore` for tests (implies `subscription`) | +//! | `subscription` | [`Subscription`] catch-up-then-live-tail loop + [`wake`] traits (dep-free; in-process wake impl lives in `nexus-wake`) | //! //! # Design notes //! @@ -100,8 +99,6 @@ pub mod execute; pub mod export; #[cfg(feature = "import")] pub mod import; -#[cfg(feature = "subscription")] -pub mod notify; #[cfg(feature = "projection")] pub mod projection; pub mod repository; @@ -117,8 +114,8 @@ pub mod stream_id; pub mod subscription; #[cfg(feature = "subscription")] pub(crate) mod subscription_cursor; -#[cfg(feature = "testing")] -pub mod testing; +#[cfg(all(test, feature = "subscription"))] +pub(crate) mod test_support; pub mod upcasting; pub mod value; #[cfg(feature = "subscription")] @@ -170,8 +167,6 @@ pub use saga::{ }; #[cfg(feature = "snapshot")] pub use snapshot::Snapshotting; -#[cfg(feature = "testing")] -pub use state::InMemorySnapshotStore; pub use state::{ AfterEventTypes, CodecSnapshotStore, CodecSnapshotStoreError, EveryNEvents, Hydrated, PersistTrigger, SnapshotStore, @@ -187,8 +182,6 @@ pub use stream_id::StreamKey; pub use futures_core::Stream; #[cfg(feature = "subscription")] pub use subscription::Subscription; -#[cfg(feature = "testing")] -pub use testing::InMemoryStoreError; pub use upcasting::EventMorsel; pub use value::{EventType, Metadata, Payload, SchemaVersion, ValueError}; #[cfg(feature = "subscription")] diff --git a/crates/nexus-store/src/projection.rs b/crates/nexus-store/src/projection.rs index f5dab987..1b0550b4 100644 --- a/crates/nexus-store/src/projection.rs +++ b/crates/nexus-store/src/projection.rs @@ -267,371 +267,3 @@ pub enum ProjectionError { #[error("snapshot commit failed")] Commit(#[source] SErr), } - -#[cfg(all(test, feature = "testing"))] -mod tests { - #![allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - reason = "test module" - )] - - use core::num::{NonZeroU32, NonZeroU64}; - - use nexus::{DomainEvent, Message, Version, version}; - - use super::{Projection, ProjectionError, Projector}; - use crate::decoded::Decoded; - use crate::state::{EveryNEvents, Hydrated, InMemorySnapshotStore, SnapshotStore}; - - // ── fixtures ──────────────────────────────────────────────────────────── - - #[derive(Debug, Clone, Hash, PartialEq, Eq)] - struct TestId(&'static str); - impl core::fmt::Display for TestId { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str(self.0) - } - } - impl AsRef<[u8]> for TestId { - fn as_ref(&self) -> &[u8] { - self.0.as_bytes() - } - } - - #[derive(Debug, Clone, PartialEq)] - struct CountState { - count: u64, - total: u64, - } - - #[derive(Debug)] - enum TestEvent { - Added(u64), - Removed(u64), - } - impl Message for TestEvent {} - impl DomainEvent for TestEvent { - fn name(&self) -> &'static str { - match self { - Self::Added(_) => "Added", - Self::Removed(_) => "Removed", - } - } - } - - #[derive(Debug, thiserror::Error)] - #[error("projection overflow")] - struct TestProjectionError; - - struct CountingProjector; - impl Projector for CountingProjector { - type Event = TestEvent; - type State = CountState; - type Error = TestProjectionError; - - fn initial(&self) -> CountState { - CountState { count: 0, total: 0 } - } - fn apply( - &self, - state: CountState, - event: &TestEvent, - ) -> Result { - let count = state.count.checked_add(1).ok_or(TestProjectionError)?; - let total = match event { - TestEvent::Added(n) => state.total.checked_add(*n).ok_or(TestProjectionError)?, - TestEvent::Removed(n) => state.total.checked_sub(*n).ok_or(TestProjectionError)?, - }; - Ok(CountState { count, total }) - } - } - - /// Build a `Decoded` event at a given version, the way `.decoded()` would. - const fn decoded(event: TestEvent, ver: u64) -> Decoded { - Decoded { - event, - version: Version::new(ver).expect("nonzero version"), - metadata: None, - } - } - - fn store() -> InMemorySnapshotStore { - InMemorySnapshotStore::new() - } - - // ── 1. Sequence/Protocol: load → advance×n commits and folds ──────────── - - #[tokio::test] - async fn advance_folds_and_commits_each_event_when_trigger_always_fires() { - let ss = store(); - let id = TestId("s"); - let (mut p, mut state) = Projection::load( - id.clone(), - CountingProjector, - EveryNEvents(NonZeroU64::MIN), - &ss, - NonZeroU32::MIN, - ) - .await - .unwrap(); - - assert_eq!(p.checkpoint(), None); - state = p - .advance(state, decoded(TestEvent::Added(10), 1)) - .await - .unwrap(); - state = p - .advance(state, decoded(TestEvent::Added(20), 2)) - .await - .unwrap(); - state = p - .advance(state, decoded(TestEvent::Added(30), 3)) - .await - .unwrap(); - - assert_eq!(p.checkpoint(), Some(version!(3))); - assert_eq!( - state, - CountState { - count: 3, - total: 60 - } - ); - - // Persisted together, atomically. - let (pos, st) = ss - .hydrate(&id, NonZeroU32::MIN) - .await - .unwrap() - .into_found() - .unwrap(); - assert_eq!(pos, version!(3)); - assert_eq!( - st, - CountState { - count: 3, - total: 60 - } - ); - } - - // ── 2. Lifecycle: commit → reload → resume from checkpoint ─────────────── - - #[tokio::test] - async fn load_resumes_state_and_checkpoint_from_snapshot() { - let ss = store(); - let id = TestId("s"); - - { - let (mut p, mut state) = Projection::load( - id.clone(), - CountingProjector, - EveryNEvents(NonZeroU64::MIN), - &ss, - NonZeroU32::MIN, - ) - .await - .unwrap(); - state = p - .advance(state, decoded(TestEvent::Added(10), 1)) - .await - .unwrap(); - state = p - .advance(state, decoded(TestEvent::Added(20), 2)) - .await - .unwrap(); - let _ = p - .advance(state, decoded(TestEvent::Added(30), 3)) - .await - .unwrap(); - } - - // Fresh stepper over the same snapshot store: state + checkpoint restored. - let (mut p2, state2) = Projection::load( - id.clone(), - CountingProjector, - EveryNEvents(NonZeroU64::MIN), - &ss, - NonZeroU32::MIN, - ) - .await - .unwrap(); - assert_eq!(p2.checkpoint(), Some(version!(3))); - assert_eq!( - state2, - CountState { - count: 3, - total: 60 - } - ); - - // Resume: the next event folds onto the restored state. - let resumed = p2 - .advance(state2, decoded(TestEvent::Added(40), 4)) - .await - .unwrap(); - assert_eq!(p2.checkpoint(), Some(version!(4))); - assert_eq!( - resumed, - CountState { - count: 4, - total: 100 - } - ); - } - - // ── 3. Defensive boundary: a failing apply surfaces as ::Apply ─────────── - - #[tokio::test] - async fn advance_surfaces_projector_apply_error() { - let ss = store(); - let (mut p, state) = Projection::load( - TestId("s"), - CountingProjector, - EveryNEvents(NonZeroU64::MIN), - &ss, - NonZeroU32::MIN, - ) - .await - .unwrap(); - - // Removed(1) from total 0 underflows in the projector. - let err = p - .advance(state, decoded(TestEvent::Removed(1), 1)) - .await - .unwrap_err(); - assert!( - matches!(err, ProjectionError::Apply(_)), - "expected Apply, got {err:?}" - ); - // Nothing was committed on the failed fold. - assert_eq!(p.checkpoint(), None); - } - - // ── flush semantics: tail is committed once on shutdown ────────────────── - - #[tokio::test] - async fn flush_commits_folded_but_unpersisted_tail() { - let ss = store(); - let id = TestId("s"); - // Trigger never fires (bucket of 100) — only flush persists. - let (mut p, mut state) = Projection::load( - id.clone(), - CountingProjector, - EveryNEvents(NonZeroU64::new(100).unwrap()), - &ss, - NonZeroU32::MIN, - ) - .await - .unwrap(); - - state = p - .advance(state, decoded(TestEvent::Added(10), 1)) - .await - .unwrap(); - state = p - .advance(state, decoded(TestEvent::Added(20), 2)) - .await - .unwrap(); - assert_eq!(p.checkpoint(), None, "trigger must not have fired"); - assert_eq!( - ss.hydrate(&id, NonZeroU32::MIN).await.unwrap(), - Hydrated::Absent - ); - - p.flush(&state).await.unwrap(); - assert_eq!(p.checkpoint(), Some(version!(2))); - let (pos, st) = ss - .hydrate(&id, NonZeroU32::MIN) - .await - .unwrap() - .into_found() - .unwrap(); - assert_eq!(pos, version!(2)); - assert_eq!( - st, - CountState { - count: 2, - total: 30 - } - ); - } - - #[tokio::test] - async fn flush_is_a_noop_when_nothing_is_pending() { - let ss = store(); - let id = TestId("s"); - let (mut p, state) = Projection::load( - id.clone(), - CountingProjector, - EveryNEvents(NonZeroU64::MIN), - &ss, - NonZeroU32::MIN, - ) - .await - .unwrap(); - - // Never advanced: flush must not write a spurious snapshot. - p.flush(&state).await.unwrap(); - assert_eq!(p.checkpoint(), None); - assert_eq!( - ss.hydrate(&id, NonZeroU32::MIN).await.unwrap(), - Hydrated::Absent - ); - } - - // ── defensive: a schema-mismatched snapshot starts fresh, but flags a rebuild ─ - - #[tokio::test] - async fn load_stale_schema_starts_from_initial_and_signals_rebuild() { - let ss = store(); - let id = TestId("s"); - // Pre-commit a v1 snapshot. - ss.commit( - &id, - NonZeroU32::MIN, - version!(5), - &CountState { - count: 99, - total: 999, - }, - ) - .await - .unwrap(); - - // Load with schema v2 — the v1 snapshot must not be restored... - let (p, state) = Projection::load( - id, - CountingProjector, - EveryNEvents(NonZeroU64::MIN), - &ss, - NonZeroU32::new(2).unwrap(), - ) - .await - .unwrap(); - assert_eq!(p.checkpoint(), None); - assert_eq!(state, CountState { count: 0, total: 0 }); - // ...but the rebuild is *visible*: a host can tell this apart from a - // fresh start (which reports `None`) — the whole point of the Stale - // signal. The invalidated schema version (v1) is reported. - assert_eq!(p.rebuilding_from(), Some(NonZeroU32::MIN)); - } - - #[tokio::test] - async fn load_fresh_does_not_signal_rebuild() { - let ss = store(); - let (p, _state) = Projection::load( - TestId("s"), - CountingProjector, - EveryNEvents(NonZeroU64::MIN), - &ss, - NonZeroU32::MIN, - ) - .await - .unwrap(); - // A genuinely-new projection is not a rebuild. - assert_eq!(p.rebuilding_from(), None); - } -} diff --git a/crates/nexus-store/src/state.rs b/crates/nexus-store/src/state.rs index 47fbc42d..8abcf412 100644 --- a/crates/nexus-store/src/state.rs +++ b/crates/nexus-store/src/state.rs @@ -319,79 +319,3 @@ pub enum CodecSnapshotStoreError { #[error("envelope synthesis error: {0}")] EnvelopeSynthesis(#[source] crate::envelope::ForDecodeError), } - -// ═══════════════════════════════════════════════════════════════════════════ -// In-memory testing fake (feature-gated) -// ═══════════════════════════════════════════════════════════════════════════ - -#[cfg(feature = "testing")] -mod testing { - use std::collections::HashMap; - use std::convert::Infallible; - use std::num::NonZeroU32; - - use nexus::Id; - use tokio::sync::RwLock; - - use super::{Hydrated, SnapshotStore}; - - /// In-memory snapshot store for tests. - #[derive(Debug, Default)] - pub struct InMemorySnapshotStore { - snapshots: RwLock>, - } - - impl InMemorySnapshotStore { - #[must_use] - pub fn new() -> Self { - Self { - snapshots: RwLock::new(HashMap::new()), - } - } - } - - impl SnapshotStore for InMemorySnapshotStore - where - S: Clone + Send + Sync + 'static, - P: Clone + Send + Sync + 'static, - { - type Error = Infallible; - - async fn hydrate( - &self, - id: &impl Id, - schema_version: NonZeroU32, - ) -> Result, Infallible> { - let snapshots = self.snapshots.read().await; - Ok(match snapshots.get(&id.to_string()) { - None => Hydrated::Absent, - Some((stored_schema, _, _)) if *stored_schema != schema_version => { - Hydrated::Stale { - stored_schema: *stored_schema, - } - } - Some((_, position, state)) => Hydrated::Found { - position: position.clone(), - state: state.clone(), - }, - }) - } - - async fn commit( - &self, - id: &impl Id, - schema_version: NonZeroU32, - position: P, - state: &S, - ) -> Result<(), Infallible> { - self.snapshots - .write() - .await - .insert(id.to_string(), (schema_version, position, state.clone())); - Ok(()) - } - } -} - -#[cfg(feature = "testing")] -pub use testing::InMemorySnapshotStore; diff --git a/crates/nexus-store/src/subscription_cursor.rs b/crates/nexus-store/src/subscription_cursor.rs index 0b34dfb8..7cfb4846 100644 --- a/crates/nexus-store/src/subscription_cursor.rs +++ b/crates/nexus-store/src/subscription_cursor.rs @@ -146,7 +146,7 @@ where }) } -#[cfg(all(test, feature = "testing"))] +#[cfg(test)] #[allow(clippy::unwrap_used, reason = "test code")] #[allow(clippy::shadow_reuse, reason = "test code: env rebinds per loop turn")] #[allow( @@ -172,12 +172,12 @@ mod tests { use crate::envelope::pending_envelope; use crate::store::RawEventStore; use crate::stream_id::StreamKey; - use crate::testing::InMemoryStore; + use crate::test_support::TestStore; const MUST_DELIVER: Duration = Duration::from_secs(5); /// Append events with versions `lo..=hi` to stream `id`. - async fn seed_range(store: &InMemoryStore, id: &StreamKey, lo: u64, hi: u64) { + async fn seed_range(store: &TestStore, id: &StreamKey, lo: u64, hi: u64) { for v in lo..=hi { let env = pending_envelope(Version::new(v).unwrap()) .event_type("E") @@ -212,7 +212,7 @@ mod tests { /// Catch-up delivers the full backlog in strict version order. #[tokio::test] async fn catch_up_yields_backlog_in_order() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); let id = StreamKey::from_slice(b"s"); seed_range(&store, &id, 1, 5).await; @@ -234,7 +234,7 @@ mod tests { /// This exercises the arm/park lost-wakeup path. #[tokio::test] async fn live_tail_sees_post_subscribe_append() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); let id = StreamKey::from_slice(b"s"); seed_range(&store, &id, 1, 1).await; @@ -274,7 +274,7 @@ mod tests { #[tokio::test] async fn chunk_boundary_no_duplicate_no_gap() { let total = u64::try_from(CATCHUP_CHUNK).unwrap() + 3; - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); let id = StreamKey::from_slice(b"s"); seed_range(&store, &id, 1, total).await; @@ -298,7 +298,7 @@ mod tests { /// stream must reach the parked cursor live (the `$all` wake path). #[tokio::test] async fn all_catchup_yields_global_order_then_live_append() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); // Interleave across two streams so global_seq spans both: a@1, b@1, a@2. seed_range(&store, &StreamKey::from_slice(b"a"), 1, 1).await; seed_range(&store, &StreamKey::from_slice(b"b"), 1, 1).await; @@ -362,7 +362,7 @@ mod tests { // ── Error propagation ──────────────────────────────────────────────────── - /// A test-only error so the mock `Catchup`'s scan can fail. `InMemoryStore` + /// A test-only error so the mock `Catchup`'s scan can fail. `TestStore` /// reads never fail, so the only way to exercise the loop's error path is to /// inject a failing dependency at the `Catchup` seam. The SUT under test is /// `live`; this is a failing dependency, NOT a reimplementation of the loop. @@ -380,7 +380,7 @@ mod tests { /// A `Catchup` whose scan yields one `Ok(env)` then one `Err(BoomError)`. /// The scan never exhausts before the `Err`, so `arm` is never reached by /// the test; it returns a ready future for completeness. The `Ok` envelope - /// is a real [`PersistedEnvelope`] read back from an [`InMemoryStore`] (not + /// is a real [`PersistedEnvelope`] read back from an [`TestStore`] (not /// fabricated), so only the error is synthetic. struct FailingCatchup { ok_env: PersistedEnvelope, @@ -417,7 +417,7 @@ mod tests { #[tokio::test] async fn scan_item_error_is_surfaced_in_order() { // Read back a real PersistedEnvelope to feed the mock's Ok item. - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); seed_range(&store, &StreamKey::from_slice(b"s"), 1, 1).await; let (_pos, ok_env) = store .read_all(None) @@ -452,7 +452,7 @@ mod tests { /// `Step::Event`. #[tokio::test] async fn live_stepped_emits_caught_up_at_the_boundary() { - let store = Arc::new(InMemoryStore::new()); + let store = Arc::new(TestStore::new()); let id = StreamKey::from_slice(b"s"); seed_range(&store, &id, 1, 2).await; diff --git a/crates/nexus-store/src/test_support.rs b/crates/nexus-store/src/test_support.rs new file mode 100644 index 00000000..ae17c04e --- /dev/null +++ b/crates/nexus-store/src/test_support.rs @@ -0,0 +1,226 @@ +//! In-crate store double for the white-box subscription-loop tests +//! (`catchup.rs`, `subscription_cursor.rs`). +//! +//! Those tests probe `pub(crate)` seams (`StreamCatchup`, `AllCatchup`, +//! `live_stepped`, `CATCHUP_CHUNK`), so they must run inside this crate's +//! lib-test build — where the real in-memory adapter (`nexus-inmemory`) +//! cannot be used: a dev-dependency cycle unifies types only for integration +//! tests in `tests/`, while the lib-test target recompiles this crate under +//! `cfg(test)` as a *distinct* crate, so `nexus-inmemory`'s trait impls name +//! the other build's traits and satisfy none of this build's bounds. +//! +//! [`TestStore`] therefore implements just enough of [`RawEventStore`] + +//! [`WakeSource`] to drive the loop. Its wake is a single store-wide +//! generation counter — every wake rouses every waiter, which the +//! [`WakeRegistration`] contract explicitly permits as a spurious wake. The +//! public subscription surface is separately proven against the real adapter +//! and the real `nexus-wake` registry in `tests/subscription_tests.rs`. + +use core::future::Future; +use std::collections::HashMap; +use std::convert::Infallible; +use std::num::NonZeroU64; + +use thiserror::Error; +use tokio::sync::{Mutex, watch}; + +use nexus::{ErrorId, Version}; + +use crate::envelope::{EnvelopeError, PendingEnvelope, PersistedEnvelope}; +use crate::error::AppendError; +use crate::store::{AllPosition, RawEventStore}; +use crate::stream_id::StreamKey; +use crate::wake::{WakeRegistration, WakeSource}; +use crate::wire::{self, WireError}; + +/// Error domain of [`TestStore`]. +#[derive(Debug, Error)] +pub enum TestStoreError { + /// Envelope failed wire-format validation when building the row. + #[error("wire-format build error in test store")] + Wire(#[from] WireError), + + /// Persisted envelope failed integrity validation. + #[error("envelope integrity error in test store")] + Envelope(#[from] EnvelopeError), +} + +/// [`TestStore`]'s `$all` position — dense append order (a double need not +/// exercise gap tolerance; the adapter conformance suites do). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TestAllPos(NonZeroU64); + +impl TestAllPos { + pub const fn as_u64(self) -> u64 { + self.0.get() + } +} + +impl AllPosition for TestAllPos {} + +/// Minimal in-crate [`RawEventStore`] + [`WakeSource`] double. +#[derive(Debug)] +pub struct TestStore { + inner: Mutex, + wake_tx: watch::Sender, +} + +#[derive(Debug, Default)] +struct Inner { + streams: HashMap, Vec>, + all: Vec<(TestAllPos, PersistedEnvelope)>, +} + +impl TestStore { + pub fn new() -> Self { + Self { + inner: Mutex::new(Inner::default()), + wake_tx: watch::Sender::new(0), + } + } +} + +fn persist(env: &PendingEnvelope) -> Result { + let frame = wire::encode_frame( + env.schema_version_value(), + &env.event_type_value(), + &env.payload_value(), + env.metadata_value().as_ref(), + )?; + Ok(PersistedEnvelope::try_new( + env.version(), + frame.value, + env.schema_version_value(), + frame.offsets.event_type, + frame.offsets.payload, + frame.offsets.metadata, + )?) +} + +impl RawEventStore for TestStore { + type Error = TestStoreError; + type Stream = + futures::stream::Iter>>; + type AllPosition = TestAllPos; + type AllStream = futures::stream::Iter< + std::vec::IntoIter>, + >; + + async fn append( + &self, + id: &StreamKey, + expected_version: Option, + envelopes: &[PendingEnvelope], + ) -> Result<(), AppendError> { + let mut inner = self.inner.lock().await; + let head = inner + .streams + .get(id.as_bytes()) + .and_then(|rows| rows.last()) + .map(PersistedEnvelope::version); + if head != expected_version { + return Err(AppendError::Conflict { + stream_id: ErrorId::from_display(id), + expected: expected_version, + actual: head, + }); + } + let persisted: Vec = envelopes + .iter() + .map(persist) + .collect::>() + .map_err(AppendError::Store)?; + for env in persisted { + let next = inner + .all + .len() + .checked_add(1) + .and_then(|n| u64::try_from(n).ok()) + .and_then(NonZeroU64::new) + .map(TestAllPos); + // Vec length + 1 always fits u64 and is nonzero; expressed as a + // checked chain to honour the arithmetic-safety rule anyway. + let Some(pos) = next else { + unreachable!("Vec length + 1 is always a valid TestAllPos") + }; + inner.all.push((pos, env.clone())); + inner + .streams + .entry(id.as_bytes().to_vec()) + .or_default() + .push(env); + } + drop(inner); + self.wake_tx.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + Ok(()) + } + + async fn read_stream( + &self, + id: &StreamKey, + from: Version, + ) -> Result { + let inner = self.inner.lock().await; + let rows: Vec> = inner + .streams + .get(id.as_bytes()) + .into_iter() + .flatten() + .filter(|env| env.version() >= from) + .cloned() + .map(Ok) + .collect(); + drop(inner); + Ok(futures::stream::iter(rows)) + } + + async fn read_all(&self, from: Option) -> Result { + let inner = self.inner.lock().await; + let rows: Vec> = inner + .all + .iter() + .filter(|(pos, _)| from.is_none_or(|f| *pos > f)) + .cloned() + .map(Ok) + .collect(); + drop(inner); + Ok(futures::stream::iter(rows)) + } +} + +/// Store-wide generation wait: any commit wakes every armed waiter (a +/// permitted spurious wake), and `mark_unchanged` at arm time pins the seen +/// generation so a wake between `arm` and the await is never lost. +#[derive(Debug)] +pub struct TestWakeReg { + rx: watch::Receiver, +} + +impl WakeRegistration for TestWakeReg { + fn arm(&self) -> impl Future + Send + 'static { + let mut rx = self.rx.clone(); + rx.mark_unchanged(); + async move { + let _ = rx.changed().await; + } + } +} + +impl WakeSource for TestStore { + type Registration = TestWakeReg; + type Error = Infallible; + + fn register(&self, _stream: Option<&[u8]>) -> Result { + Ok(TestWakeReg { + rx: self.wake_tx.subscribe(), + }) + } + + fn wake(&self, _stream: &[u8]) { + self.wake_tx.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + } +} diff --git a/crates/nexus-store/src/wake.rs b/crates/nexus-store/src/wake.rs index 81969824..d4ed0928 100644 --- a/crates/nexus-store/src/wake.rs +++ b/crates/nexus-store/src/wake.rs @@ -1,9 +1,9 @@ //! Adapter-pluggable wake mechanism for live subscriptions. //! //! The generic subscription loop parks on a [`WakeRegistration`] until new -//! events may exist. In-process adapters use -//! [`StreamNotifiers`](crate::notify::StreamNotifiers); distributed adapters -//! (e.g. postgres) implement these traits over `LISTEN`/`NOTIFY`. +//! events may exist. In-process adapters use `nexus_wake::StreamNotifiers`; +//! distributed adapters (e.g. postgres) implement these traits over +//! `LISTEN`/`NOTIFY`. //! //! Only ever used as a generic bound (never `dyn`), so `arm` is an RPITIT //! future — no associated `Wait` type, no boxing. @@ -38,97 +38,3 @@ pub trait WakeRegistration: Send + 'static { /// borrow of `self`). fn arm(&self) -> impl Future + Send + 'static; } - -#[cfg(test)] -#[allow(clippy::unwrap_used, reason = "test code")] -mod tests { - use super::*; - use crate::notify::StreamNotifiers; - use std::sync::Arc; - use std::time::Duration; - use tokio::sync::Barrier; - use tokio::time::timeout; - - const MUST_WAKE: Duration = Duration::from_secs(5); - - /// Ported lost-wakeup test: a registration armed before a concurrent wake - /// must never miss it. Repeated to shake out scheduling races. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn armed_wait_never_loses_a_concurrent_wake() { - for _ in 0..50 { - let reg = StreamNotifiers::new(); - let registration = reg.register(Some(b"k")).unwrap(); - let wait = registration.arm(); // armed BEFORE the race - let start = Arc::new(Barrier::new(2)); - - let start_prod = Arc::clone(&start); - let reg_prod = Arc::clone(®); - let prod = tokio::spawn(async move { - start_prod.wait().await; - reg_prod.wake(b"k"); - }); - - start.wait().await; - timeout(MUST_WAKE, wait) - .await - .expect("an armed wait must not lose a concurrent wake"); - prod.await.unwrap(); - } - } - - /// $all registration wakes on any stream's wake. - #[tokio::test] - async fn all_registration_wakes_on_any_stream() { - let reg = StreamNotifiers::new(); - let registration = reg.register(None).unwrap(); - let wait = registration.arm(); - reg.wake(b"any-stream"); - timeout(MUST_WAKE, wait) - .await - .expect("$all must wake on any stream wake"); - } - - /// Drives the wake purely through the `WakeSource` trait surface (a generic - /// bound, the way the subscription loop uses it): register, arm, then call - /// the *trait* `wake`. Proves the trait method is not the recursive shadow - /// of the inherent one (a recursion would hang and time out here) and that - /// a per-stream wake reaches both a per-stream and an `$all` registration. - #[tokio::test] - async fn trait_wake_routes_to_stream_and_all() { - async fn arm_and_wake(src: &W) { - let per_stream = src.register(Some(b"k")).unwrap(); - let all = src.register(None).unwrap(); - let wait_stream = per_stream.arm(); - let wait_all = all.arm(); - // Trait-dispatched wake (not the inherent method). - WakeSource::wake(src, b"k"); - timeout(MUST_WAKE, wait_stream) - .await - .expect("trait wake must rouse the per-stream registration"); - timeout(MUST_WAKE, wait_all) - .await - .expect("trait wake must rouse the $all registration"); - } - - let reg = StreamNotifiers::new(); - arm_and_wake(reg.as_ref()).await; - } - - /// Dropping a per-stream `WakeReg` reaps the entry through the guard chain. - #[tokio::test] - async fn dropping_registration_reaps_entry() { - let reg = StreamNotifiers::new(); - let registration = reg.register(Some(b"s")).unwrap(); - assert_eq!( - reg.active_streams(), - 1, - "register(Some) must create one entry" - ); - drop(registration); - assert_eq!( - reg.active_streams(), - 0, - "dropping the registration must reap the entry" - ); - } -} diff --git a/crates/nexus-store/tests/adversarial_property_tests.rs b/crates/nexus-store/tests/adversarial_property_tests.rs index 8c8fe4ef..4f0ea404 100644 --- a/crates/nexus-store/tests/adversarial_property_tests.rs +++ b/crates/nexus-store/tests/adversarial_property_tests.rs @@ -68,7 +68,8 @@ use std::sync::Arc; use bytes::Bytes; use futures::StreamExt; use nexus::{ErrorId, Version}; -use nexus_store::InMemoryStoreError; +use nexus_inmemory::InMemoryStore; +use nexus_inmemory::InMemoryStoreError; use nexus_store::Repository; use nexus_store::Store; use nexus_store::codec::{Decode, Encode}; @@ -76,7 +77,6 @@ use nexus_store::envelope::{PendingEnvelope, PersistedEnvelope}; use nexus_store::error::StoreError; use nexus_store::pending_envelope; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::upcasting::EventMorsel; use nexus_store::value::{EventType, Metadata, Payload, SchemaVersion}; use nexus_store::wire; diff --git a/crates/nexus-store/tests/borrowing_codec_tests.rs b/crates/nexus-store/tests/borrowing_codec_tests.rs index 68975e49..3760b27b 100644 --- a/crates/nexus-store/tests/borrowing_codec_tests.rs +++ b/crates/nexus-store/tests/borrowing_codec_tests.rs @@ -12,9 +12,9 @@ use std::fmt; use nexus::*; +use nexus_inmemory::InMemoryStore; use nexus_store::Repository; use nexus_store::Store; -use nexus_store::testing::InMemoryStore; use nexus_store::{Decode, Encode, PersistedEnvelope}; /// A test codec that "decodes" by reinterpreting bytes as a u32 slice. diff --git a/crates/nexus-store/tests/bounded_batch_proptest.rs b/crates/nexus-store/tests/bounded_batch_proptest.rs index c19c3e17..2a055749 100644 --- a/crates/nexus-store/tests/bounded_batch_proptest.rs +++ b/crates/nexus-store/tests/bounded_batch_proptest.rs @@ -9,11 +9,11 @@ use futures::StreamExt; use nexus::Version; +use nexus_inmemory::InMemoryStore; use nexus_store::StreamKey; use nexus_store::batch::BatchSize; use nexus_store::envelope::pending_envelope; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use proptest::prelude::*; fn batch_strategy() -> impl Strategy { diff --git a/crates/nexus-store/tests/bug_hunt_tests.rs b/crates/nexus-store/tests/bug_hunt_tests.rs index 8fc97ffa..dfbfa004 100644 --- a/crates/nexus-store/tests/bug_hunt_tests.rs +++ b/crates/nexus-store/tests/bug_hunt_tests.rs @@ -28,8 +28,8 @@ )] use nexus::{ErrorId, Version}; +use nexus_inmemory::InMemoryStoreError; use nexus_store::AppendError; -use nexus_store::InMemoryStoreError; use nexus_store::envelope::{PendingEnvelope, PersistedEnvelope}; use nexus_store::error::StoreError; use nexus_store::pending_envelope; @@ -110,7 +110,7 @@ impl futures::Stream for ProbeStream { impl RawEventStore for ProbeStore { type Error = ProbeError; type Stream = ProbeStream; - type AllPosition = nexus_store::testing::InMemoryAllPos; + type AllPosition = nexus_inmemory::InMemoryAllPos; type AllStream = futures::stream::Empty>; diff --git a/crates/nexus-store/tests/cbor_pipeline_tests.rs b/crates/nexus-store/tests/cbor_pipeline_tests.rs new file mode 100644 index 00000000..4adfe64c --- /dev/null +++ b/crates/nexus-store/tests/cbor_pipeline_tests.rs @@ -0,0 +1,92 @@ +//! Full export → CBOR box → import pipeline over the in-memory adapter — +//! relocated from `src/cbor.rs` (nexus-inmemory is a dev-dependency; type +//! unification with it requires an integration test). + +#![cfg(feature = "cbor")] + +use futures::StreamExt; +use nexus::Version; +use nexus_inmemory::InMemoryStore; +use nexus_store::envelope::pending_envelope; +use nexus_store::import::{Atomicity, EventImporter}; +use nexus_store::store::RawEventStore; +use nexus_store::stream_id::StreamKey; +use nexus_store::{ChunkWriter, decode_chunk}; + +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test code asserts exact values" +)] +mod pipeline { + use super::*; + + #[tokio::test] + async fn export_box_import_round_trip_byte_equal_modulo_global_seq() { + // Seed a source store with two streams. + let src = InMemoryStore::new(); + for (sid, count) in [("task-1", 3u64), ("task-2", 2)] { + for v in 1..=count { + let pe = pending_envelope(Version::new(v).expect("nonzero")) + .event_type("E") + .payload(format!("{sid}-{v}").into_bytes()) + .build() + .expect("valid envelope"); + src.append( + &StreamKey::from_slice(sid.as_bytes()), + Version::new(v - 1), + core::slice::from_ref(&pe), + ) + .await + .expect("append"); + } + } + + // Export → box-encode into one chunk. + let mut w = ChunkWriter::new(Vec::new(), Some(b"src")).expect("writer"); + for sid in ["task-1", "task-2"] { + let s = src + .read_stream(&StreamKey::from_slice(sid.as_bytes()), Version::INITIAL) + .await + .expect("read"); + w.section(sid.as_bytes()) + .expect("section") + .try_extend(s) + .await + .expect("extend"); + } + let chunk = w.into_sink(); + + // Box-decode → import into a fresh store under origin-namespaced ids. + let sections = decode_chunk(&chunk).expect("decode"); + let dst = InMemoryStore::new(); + let route = |origin: &[u8]| { + StreamKey::from_slice(format!("src:{}", String::from_utf8_lossy(origin)).as_bytes()) + }; + let report = dst + .import(§ions, route, Atomicity::PerStream) + .await + .expect("import"); + assert!(report.all_complete()); + + // Verify byte-equality of payloads/versions modulo global_seq. + for sid in ["task-1", "task-2"] { + let target = StreamKey::from_slice(format!("src:{sid}").as_bytes()); + let got: Vec<(u64, Vec)> = dst + .read_stream(&target, Version::INITIAL) + .await + .expect("read") + .map(|r| { + let e = r.expect("no err"); + (e.version().as_u64(), e.payload().to_vec()) + }) + .collect() + .await; + let expected: Vec<(u64, Vec)> = (1..=if sid == "task-1" { 3u64 } else { 2 }) + .map(|v| (v, format!("{sid}-{v}").into_bytes())) + .collect(); + assert_eq!(got, expected, "stream {sid} round-trips"); + } + } +} diff --git a/crates/nexus-store/tests/decoded_inline_tests.rs b/crates/nexus-store/tests/decoded_inline_tests.rs new file mode 100644 index 00000000..a67b2bed --- /dev/null +++ b/crates/nexus-store/tests/decoded_inline_tests.rs @@ -0,0 +1,112 @@ +//! Relocated inline test mod of `src/decoded.rs` (nexus-inmemory is a +//! dev-dependency; type unification with it requires an integration test). + +use nexus::Version; + +use nexus_store::envelope::PersistedEnvelope; + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "tests")] +#[allow(clippy::expect_used, reason = "tests")] +mod tests { + use super::*; + use futures::StreamExt; + use nexus_inmemory::InMemoryStore; + use nexus_store::Store; + use nexus_store::decoded::*; + use nexus_store::pending_envelope; + use nexus_store::store::RawEventStore; + use nexus_store::stream_id::StreamKey; + + /// Build a real `PersistedEnvelope` by round-tripping through + /// `InMemoryStore`: append a `PendingEnvelope`, then read it back. There is + /// no test-only `PendingEnvelope -> PersistedEnvelope` constructor in + /// `envelope.rs` (`PersistedEnvelope::for_decode` synthesizes an envelope + /// but carries no version, so it cannot exercise the `version` field this + /// module's tests need) — this mirrors the exact pattern used by + /// `crates/nexus-store/tests/subscription_tests.rs`. + async fn env(version: u64, meta: Option<&[u8]>) -> PersistedEnvelope { + let store = Store::new(InMemoryStore::new()); + let id = StreamKey::from_slice(b"stream-1"); + + // `append`'s `expected_version` is the CURRENT version (`None` = empty + // stream); to land an event at an arbitrary target `version` the + // stream must first be filled up to `version - 1`. + let mut expected = None; + for filler_version in 1..version { + let filler = pending_envelope(Version::new(filler_version).expect("nonzero version")) + .event_type("Filler") + .payload(b"filler".to_vec()) + .build() + .expect("valid envelope"); + store + .append(&id, expected, &[filler]) + .await + .expect("append succeeds"); + expected = Version::new(filler_version); + } + + let mut builder = pending_envelope(Version::new(version).expect("nonzero version")) + .event_type("E") + .payload(b"payload".to_vec()); + if let Some(m) = meta { + builder = builder.metadata(m.to_vec()); + } + let envelope = builder.build().expect("valid envelope"); + store + .append(&id, expected, &[envelope]) + .await + .expect("append succeeds"); + + let raw_stream = store + .read_stream(&id, Version::new(version).expect("nonzero version")) + .await + .expect("read_stream succeeds"); + let mut stream = std::pin::pin!(raw_stream); + stream + .next() + .await + .expect("at least one event") + .expect("read succeeds") + } + + #[tokio::test] + async fn retag_on_bare_envelope_is_identity_shape() { + let e = env(3, Some(b"m")).await; + let decoded = Decoded { + event: 42u64, + version: e.version(), + metadata: e.metadata_bytes(), + }; + let typed: Decoded = e.retag(decoded); + assert_eq!(typed.event, 42); + assert_eq!(typed.version, Version::new(3).expect("nonzero version")); + assert_eq!(typed.metadata.as_deref(), Some(b"m".as_ref())); + } + + #[tokio::test] + async fn retag_on_tagged_item_copies_the_position_beside_the_box() { + let e = env(1, None).await; + let item = (99u64, e); + let decoded = Decoded { + event: 7u64, + version: item.envelope().version(), + metadata: None, + }; + let (pos, typed): (u64, Decoded) = item.retag(decoded); + assert_eq!(pos, 99); + assert_eq!(typed.event, 7); + assert_eq!(typed.version, Version::new(1).expect("nonzero version")); + } + + #[test] + fn error_variants_render_distinct_messages() { + #[derive(Debug, thiserror::Error)] + #[error("boom")] + struct Boom; + let read: DecodeStreamError = DecodeStreamError::Read(Boom); + let decode: DecodeStreamError = DecodeStreamError::Decode(Boom); + assert_eq!(read.to_string(), "subscription stream read failed"); + assert_eq!(decode.to_string(), "event decode failed"); + } +} diff --git a/crates/nexus-store/tests/decoded_view_tests.rs b/crates/nexus-store/tests/decoded_view_tests.rs index 7c6aa342..b62f1b18 100644 --- a/crates/nexus-store/tests/decoded_view_tests.rs +++ b/crates/nexus-store/tests/decoded_view_tests.rs @@ -1,4 +1,4 @@ -#![cfg(all(feature = "testing", feature = "json"))] +#![cfg(feature = "json")] #![allow(clippy::unwrap_used, reason = "tests")] #![allow(clippy::expect_used, reason = "tests")] #![allow(clippy::panic, reason = "tests")] @@ -7,8 +7,8 @@ use std::time::Duration; use futures::StreamExt; use nexus::{DomainEvent, Message, Version}; +use nexus_inmemory::InMemoryStore; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::{ Decode, DecodeStreamError, Decoded, DecodedStreamExt, Encode, FoldDecodedError, JsonCodec, PersistedEnvelope, StepStreamExt, Store, StreamKey, Subscription, pending_envelope, diff --git a/crates/nexus-store/tests/event_store_tests.rs b/crates/nexus-store/tests/event_store_tests.rs index 16e94946..a368a933 100644 --- a/crates/nexus-store/tests/event_store_tests.rs +++ b/crates/nexus-store/tests/event_store_tests.rs @@ -11,9 +11,9 @@ use std::convert::Infallible; use std::fmt; use nexus::*; +use nexus_inmemory::InMemoryStore; use nexus_store::Repository; use nexus_store::Store; -use nexus_store::testing::InMemoryStore; use nexus_store::upcasting::EventMorsel; use nexus_store::{Decode, Encode}; diff --git a/crates/nexus-store/tests/execute_tests.rs b/crates/nexus-store/tests/execute_tests.rs index a44b7547..7496e2c5 100644 --- a/crates/nexus-store/tests/execute_tests.rs +++ b/crates/nexus-store/tests/execute_tests.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use bytes::Bytes; use futures::future::join_all; use nexus::{Aggregate, AggregateState, DomainEvent, Events, Handle, Message, Version, events}; -use nexus_store::testing::InMemoryStore; +use nexus_inmemory::InMemoryStore; use nexus_store::{ CommandRepository, Decode, Encode, ExecuteError, PersistedEnvelope, Repository, Store, }; diff --git a/crates/nexus-store/tests/export_inline_tests.rs b/crates/nexus-store/tests/export_inline_tests.rs new file mode 100644 index 00000000..a5a188f5 --- /dev/null +++ b/crates/nexus-store/tests/export_inline_tests.rs @@ -0,0 +1,438 @@ +//! Relocated inline test mod of `src/export.rs` (nexus-inmemory is a +//! dev-dependency; type unification with it requires an integration test). + +#![cfg(feature = "export")] + +use nexus::Version; +use nexus_store::store::{RawEventStore, Store}; +use nexus_store::stream_id::StreamKey; + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + reason = "test code asserts exact values" +)] +mod tests { + use super::*; + use futures::StreamExt; + use nexus_inmemory::{InMemoryStore, InMemoryStoreError}; + use nexus_store::envelope::{PersistedEnvelope, pending_envelope}; + use nexus_store::export::*; + use proptest::prelude::*; + use static_assertions::assert_impl_all; + use std::collections::HashSet; + use std::sync::Arc; + use tokio::sync::Barrier; + + // Every RawEventStore gains both capabilities — the blanket impl for + // EventExporter, the adapter impl for StreamLister. + assert_impl_all!(InMemoryStore: EventExporter, StreamLister, RawEventStore); + + // ── test stream key ─────────────────────────────────────────────────────── + + fn sk(s: &str) -> StreamKey { + StreamKey::from_slice(s.as_bytes()) + } + + fn env(v: u64, payload: &[u8]) -> nexus_store::envelope::PendingEnvelope { + pending_envelope(Version::new(v).expect("nonzero")) + .event_type("E") + .payload(payload.to_vec()) + .build() + .expect("valid envelope") + } + + async fn append_one(store: &InMemoryStore, id: &StreamKey, v: u64, payload: &[u8]) { + let expected = Version::new(v - 1); + store + .append(id, expected, &[env(v, payload)]) + .await + .expect("append succeeds"); + } + + async fn seed(store: &InMemoryStore, id: &StreamKey, count: u64) { + for v in 1..=count { + append_one(store, id, v, format!("payload-{v}").as_bytes()).await; + } + } + + async fn collect_export( + store: &InMemoryStore, + id: &StreamKey, + from: Version, + ) -> Vec { + let stream = store.export_stream(id, from).await.expect("export opens"); + stream + .map(|r| r.expect("no read error")) + .collect::>() + .await + } + + // ════════════════════════════════════════════════════════════════════════ + // EventExporter — Category 1: sequence / protocol + // ════════════════════════════════════════════════════════════════════════ + + #[tokio::test] + async fn export_stream_is_byte_for_byte_the_read_stream() { + // The core contract: export is a verbatim pass-through of read_stream. + // Same versions, schema, event_type, payload, metadata (a per-stream + // event carries no global position). + let store = InMemoryStore::new(); + let id = sk("acct-1"); + seed(&store, &id, 5).await; + + let exported = collect_export(&store, &id, Version::INITIAL).await; + + let read: Vec = store + .read_stream(&id, Version::INITIAL) + .await + .expect("read opens") + .map(|r| r.expect("no read error")) + .collect() + .await; + + assert_eq!(exported.len(), read.len()); + for (e, r) in exported.iter().zip(read.iter()) { + assert_eq!(e.version(), r.version()); + assert_eq!(e.schema_version(), r.schema_version()); + assert_eq!(e.event_type(), r.event_type()); + assert_eq!(e.payload(), r.payload()); + assert_eq!(e.metadata(), r.metadata()); + } + } + + #[tokio::test] + async fn export_preserves_versions_and_payloads_in_order() { + let store = InMemoryStore::new(); + let id = sk("acct-1"); + seed(&store, &id, 5).await; + + let exported = collect_export(&store, &id, Version::INITIAL).await; + + let versions: Vec = exported.iter().map(|e| e.version().as_u64()).collect(); + assert_eq!(versions, vec![1, 2, 3, 4, 5]); + for (i, e) in exported.iter().enumerate() { + let expected = format!("payload-{}", i + 1); + assert_eq!(e.payload(), expected.as_bytes()); + } + } + + #[tokio::test] + async fn exporting_the_same_stream_twice_is_identical() { + let store = InMemoryStore::new(); + let id = sk("acct-1"); + seed(&store, &id, 4).await; + + let first = collect_export(&store, &id, Version::INITIAL).await; + let second = collect_export(&store, &id, Version::INITIAL).await; + + assert_eq!(first.len(), second.len()); + for (a, b) in first.iter().zip(second.iter()) { + assert_eq!(a.version(), b.version()); + assert_eq!(a.payload(), b.payload()); + } + } + + #[tokio::test] + async fn export_carries_metadata_and_schema_verbatim() { + let store = InMemoryStore::new(); + let id = sk("acct-1"); + let pending = pending_envelope(Version::INITIAL) + .event_type("Created") + .payload(b"body".to_vec()) + .schema_version(nexus_store::value::SchemaVersion::from_u32(7).expect("nonzero")) + .metadata(b"meta".to_vec()) + .build() + .expect("valid envelope"); + store.append(&id, None, &[pending]).await.expect("append"); + + let exported = collect_export(&store, &id, Version::INITIAL).await; + + assert_eq!(exported.len(), 1); + let e = &exported[0]; + assert_eq!(e.event_type(), "Created"); + assert_eq!(e.payload(), b"body"); + assert_eq!(e.metadata(), Some(b"meta".as_slice())); + assert_eq!(e.schema_version(), 7); + } + + // ════════════════════════════════════════════════════════════════════════ + // EventExporter — Category 2: lifecycle + // ════════════════════════════════════════════════════════════════════════ + + #[tokio::test] + async fn export_empty_stream_terminates_and_does_not_hang() { + let store = InMemoryStore::new(); + let id = sk("never-appended"); + let mut stream = store + .export_stream(&id, Version::INITIAL) + .await + .expect("opens"); + // Must terminate (None), not block forever. + assert!(stream.next().await.is_none()); + // Stays terminated. + assert!(stream.next().await.is_none()); + } + + #[tokio::test] + async fn export_from_past_the_head_is_empty() { + let store = InMemoryStore::new(); + let id = sk("acct-1"); + seed(&store, &id, 3).await; + + let exported = collect_export(&store, &id, Version::new(10).expect("nonzero")).await; + assert!(exported.is_empty(), "from past head yields nothing"); + } + + #[tokio::test] + async fn export_from_midstream_is_inclusive_of_from() { + // Pins the resolved semantics: `from` is INCLUSIVE (matches + // read_stream). from=3 on a 5-event stream yields [3,4,5]. + let store = InMemoryStore::new(); + let id = sk("acct-1"); + seed(&store, &id, 5).await; + + let exported = collect_export(&store, &id, Version::new(3).expect("nonzero")).await; + let versions: Vec = exported.iter().map(|e| e.version().as_u64()).collect(); + assert_eq!(versions, vec![3, 4, 5]); + } + + #[tokio::test] + async fn export_single_event_stream_yields_one() { + let store = InMemoryStore::new(); + let id = sk("acct-1"); + seed(&store, &id, 1).await; + + let exported = collect_export(&store, &id, Version::INITIAL).await; + assert_eq!(exported.len(), 1); + assert_eq!(exported[0].version(), Version::INITIAL); + } + + // ════════════════════════════════════════════════════════════════════════ + // EventExporter — Category 3: defensive boundary + // ════════════════════════════════════════════════════════════════════════ + + #[tokio::test] + async fn export_unknown_stream_is_empty_not_error() { + let store = InMemoryStore::new(); + // Seed a different stream so the store is non-empty. + seed(&store, &sk("other"), 2).await; + + let exported = collect_export(&store, &sk("does-not-exist"), Version::INITIAL).await; + assert!(exported.is_empty()); + } + + #[tokio::test] + async fn export_handles_empty_and_unusual_stream_ids() { + let store = InMemoryStore::new(); + // Empty id and a long id are both structurally legal stream ids. + let empty = sk(""); + let long = sk(&"x".repeat(300)); + seed(&store, &empty, 2).await; + seed(&store, &long, 3).await; + + assert_eq!( + collect_export(&store, &empty, Version::INITIAL).await.len(), + 2 + ); + assert_eq!( + collect_export(&store, &long, Version::INITIAL).await.len(), + 3 + ); + } + + // ════════════════════════════════════════════════════════════════════════ + // EventExporter — Category 4: linearizability / isolation + // ════════════════════════════════════════════════════════════════════════ + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_append_and_export_sees_a_consistent_prefix() { + // A writer appends 1..=N on a stream while an exporter drains it. The + // exporter must see a contiguous prefix starting at v1, strictly + // increasing, each event well-formed (payload matches its version) — + // never a gap, never a torn event. + let store = Arc::new(InMemoryStore::new()); + let id = sk("race"); + let n = 200u64; + + // Seed v1 BEFORE the race. Without this, the exporter can open and drain + // the stream before the writer commits its first event — a valid but + // empty prefix — making the `!exported.is_empty()` check below flaky + // (it raced pass-on-one-trigger, fail-on-another on identical commits). + // With v1 already committed, the exporter is guaranteed a non-empty + // prefix, so that assertion is a real, falsifiable guarantee rather than + // a timing coin-flip. The writer races 2..=n. + append_one(&store, &id, 1, b"payload-1").await; + + let barrier = Arc::new(Barrier::new(2)); + + let writer_store = Arc::clone(&store); + let writer_id = id.clone(); + let writer_barrier = Arc::clone(&barrier); + let writer = tokio::spawn(async move { + writer_barrier.wait().await; + for v in 2..=n { + append_one( + &writer_store, + &writer_id, + v, + format!("payload-{v}").as_bytes(), + ) + .await; + } + }); + + barrier.wait().await; + let stream = store + .export_stream(&id, Version::INITIAL) + .await + .expect("opens"); + let exported: Vec = + stream.map(|r| r.expect("no read error")).collect().await; + + writer.await.expect("writer task"); + + // Contiguous prefix from v1, strictly increasing, payload matches. + for (expected, e) in (1u64..).zip(exported.iter()) { + assert_eq!( + e.version().as_u64(), + expected, + "exported versions must be a gapless prefix from 1", + ); + assert_eq!(e.payload(), format!("payload-{expected}").as_bytes()); + } + // We may not have raced the full N in, but every event we saw is a + // valid, ordered prefix — and v1 was seeded before the race, so the + // exporter must have seen at least it. + assert!( + !exported.is_empty(), + "exporter must see at least the seeded v1" + ); + } + + // ════════════════════════════════════════════════════════════════════════ + // StreamLister + // ════════════════════════════════════════════════════════════════════════ + + async fn collect_stream_ids(store: &InMemoryStore) -> HashSet> { + let stream = store.list_streams().await.expect("list opens"); + stream + .map(|r| r.expect("no list error").as_bytes().to_vec()) + .collect::>() + .await + .into_iter() + .collect() + } + + #[tokio::test] + async fn list_streams_returns_exactly_the_appended_ids() { + let store = InMemoryStore::new(); + seed(&store, &sk("alpha"), 1).await; + seed(&store, &sk("beta"), 2).await; + seed(&store, &sk("gamma"), 3).await; + + let ids = collect_stream_ids(&store).await; + let expected: HashSet> = [b"alpha".to_vec(), b"beta".to_vec(), b"gamma".to_vec()] + .into_iter() + .collect(); + assert_eq!(ids, expected); + } + + #[tokio::test] + async fn list_streams_on_empty_store_is_empty() { + let store = InMemoryStore::new(); + let ids = collect_stream_ids(&store).await; + assert!(ids.is_empty()); + } + + #[tokio::test] + async fn list_streams_lists_each_stream_once_regardless_of_event_count() { + let store = InMemoryStore::new(); + seed(&store, &sk("busy"), 50).await; + + let stream = store.list_streams().await.expect("opens"); + let all: Vec> = stream + .map(|r| r.expect("no error").as_bytes().to_vec()) + .collect::>() + .await; + assert_eq!(all, vec![b"busy".to_vec()], "one entry, no per-event dupes"); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + #[test] + fn list_streams_matches_a_hashset_oracle( + ids in proptest::collection::hash_set("[a-z]{1,12}", 0..20), + ) { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime"); + rt.block_on(async { + let store = InMemoryStore::new(); + for s in &ids { + append_one(&store, &sk(s), 1, b"p").await; + } + let listed = collect_stream_ids(&store).await; + let oracle: HashSet> = + ids.iter().map(|s| s.as_bytes().to_vec()).collect(); + prop_assert_eq!(listed, oracle); + Ok(()) + })?; + } + } + + // Surfaces a typed error from a successful no-event read path — pins that + // the StreamList item error type is the adapter error (not boxed). + #[tokio::test] + async fn stream_list_error_type_is_the_adapter_error() { + fn assert_err_type>(_: &S) {} + let store = InMemoryStore::new(); + assert_err_type(&store); + } + + // ── #247: the Store handle is the front door — list/export, no .raw() ──── + + #[tokio::test] + async fn store_handle_lists_and_exports_without_raw() { + // Substitutable: generic `StreamLister`/`EventExporter`-bounded code + // accepts the handle directly (the structural win of #247). + fn assert_lister(_: &L) {} + fn assert_exporter(_: &E) {} + + // The handle itself appends, lists, and exports — never `.raw()`. + let store = Store::new(InMemoryStore::new()); + store + .append(&sk("acct-1"), None, &[env(1, b"x")]) + .await + .expect("append via the handle"); + + let ids: HashSet> = store + .list_streams() + .await + .expect("list via the handle") + .map(|r| r.expect("no list error").as_bytes().to_vec()) + .collect::>() + .await + .into_iter() + .collect(); + assert_eq!( + ids, + std::iter::once(b"acct-1".to_vec()).collect::>() + ); + + let exported: Vec = store + .export_stream(&sk("acct-1"), Version::INITIAL) + .await + .expect("export via the handle") + .map(|r| r.expect("no read error")) + .collect() + .await; + assert_eq!(exported.len(), 1); + assert_eq!(exported[0].version(), Version::INITIAL); + + // The generic bounds above accept the handle directly (the #247 win). + assert_lister(&store); + assert_exporter(&store); + } +} diff --git a/crates/nexus-store/tests/import_inline_tests.rs b/crates/nexus-store/tests/import_inline_tests.rs new file mode 100644 index 00000000..00505eff --- /dev/null +++ b/crates/nexus-store/tests/import_inline_tests.rs @@ -0,0 +1,1114 @@ +//! Relocated inline test mod of `src/import.rs` (nexus-inmemory is a +//! dev-dependency; type unification with it requires an integration test). +//! The store-free planner tests over the private `plan_section`/`SectionPlan` +//! stayed inline as `import::plan_tests`. + +#![cfg(feature = "import")] + +use nexus::Version; +use thiserror::Error; + +use nexus_store::envelope::PersistedEnvelope; +use nexus_store::error::AppendError; +use nexus_store::store::{RawEventStore, Store}; +use nexus_store::stream_id::StreamKey; + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test code asserts exact values" +)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::StreamExt; + use nexus_store::envelope::{PendingEnvelope, pending_envelope}; + use nexus_store::export::EventExporter; + use nexus_store::import::*; + use nexus_store::value::SchemaVersion; + use proptest::prelude::*; + use static_assertions::assert_impl_all; + use std::error::Error as _; + use std::sync::Arc; + use tokio::sync::Barrier; + + fn v(n: u64) -> Version { + Version::new(n).expect("test version must be nonzero") + } + + fn persisted(version: u64, payload: &[u8]) -> PersistedEnvelope { + let mut buf = Vec::new(); + buf.extend_from_slice(b"E"); + buf.extend_from_slice(payload); + let et_end = 1u32; + let pl_end = et_end + u32::try_from(payload.len()).expect("payload fits u32"); + PersistedEnvelope::try_new( + v(version), + Bytes::from(buf), + SchemaVersion::INITIAL, + 0..et_end, + et_end..pl_end, + None, + ) + .expect("valid persisted envelope") + } + + fn evt(version: u64) -> ImportBlock { + ImportBlock::Event(persisted(version, b"p")) + } + + fn section(origin: &str, blocks: Vec) -> StreamSection { + StreamSection { + origin: Bytes::copy_from_slice(origin.as_bytes()), + blocks, + } + } + + fn report(stream: &str, outcome: StreamOutcome) -> StreamReport { + StreamReport { + stream: StreamKey::from_slice(stream.as_bytes()), + outcome, + } + } + + // ── Send + Sync: report/outcome/error types travel between tasks ───────── + + assert_impl_all!(StreamOutcome: Send, Sync, Copy, Clone); + assert_impl_all!(Atomicity: Send, Sync, Copy, Clone); + assert_impl_all!(AbortReason: Send, Sync, Clone); + assert_impl_all!(StreamReport: Send, Sync, Clone); + assert_impl_all!(ImportReport: Send, Sync, Clone); + assert_impl_all!(ImportError: Send, Sync); + + // ── Atomicity: equality + Copy semantics ──────────────────────────────── + + #[test] + fn atomicity_equality_and_copy_semantics() { + let policy = Atomicity::WholeChunk; + let copied = policy; // `Copy`: `policy` is still usable below. + assert_eq!(policy, copied); + assert_eq!(Atomicity::WholeChunk, Atomicity::WholeChunk); + assert_eq!(Atomicity::PerStream, Atomicity::PerStream); + assert_ne!(Atomicity::WholeChunk, Atomicity::PerStream); + } + + // ── StreamOutcome::is_complete / reached — all variants ───────────────── + + #[test] + fn complete_is_complete_and_reaches_its_version() { + let outcome = StreamOutcome::Complete { version: v(5) }; + assert!(outcome.is_complete()); + assert_eq!(outcome.reached(), Some(v(5))); + } + + #[test] + fn corrupt_untouched_is_not_complete_and_reaches_none() { + let outcome = StreamOutcome::Corrupt { reached: None }; + assert!(!outcome.is_complete()); + assert_eq!(outcome.reached(), None); + } + + #[test] + fn corrupt_with_prefix_reaches_that_prefix() { + let outcome = StreamOutcome::Corrupt { + reached: Some(v(3)), + }; + assert!(!outcome.is_complete()); + assert_eq!(outcome.reached(), Some(v(3))); + } + + #[test] + fn mismatch_untouched_reaches_none_not_got() { + // reached and got are distinct values: a bug returning `got` would + // make this fail. + let outcome = StreamOutcome::Mismatch { + reached: None, + got: v(8), + }; + assert!(!outcome.is_complete()); + assert_eq!(outcome.reached(), None); + } + + #[test] + fn mismatch_with_prefix_reaches_prefix_not_got() { + let outcome = StreamOutcome::Mismatch { + reached: Some(v(3)), + got: v(8), + }; + assert!(!outcome.is_complete()); + // Must be the prefix (3), never the offending version (8). + assert_eq!(outcome.reached(), Some(v(3))); + } + + // ── ImportReport / StreamReport: new / streams / unfinished / all_complete + + #[test] + fn empty_report_is_vacuously_all_complete_with_no_unfinished() { + let report: ImportReport = ImportReport::new(Vec::new()); + assert!(report.streams().is_empty()); + assert!(report.all_complete()); + assert_eq!(report.unfinished().count(), 0); + } + + #[test] + fn all_complete_report_reports_no_unfinished() { + let streams = vec![ + report("a", StreamOutcome::Complete { version: v(1) }), + report("b", StreamOutcome::Complete { version: v(2) }), + ]; + let report = ImportReport::new(streams.clone()); + assert_eq!(report.streams(), streams.as_slice()); + assert!(report.all_complete()); + assert_eq!(report.unfinished().count(), 0); + } + + #[test] + fn mixed_report_filters_exactly_the_non_complete_streams() { + let streams = vec![ + report("done-1", StreamOutcome::Complete { version: v(1) }), + report( + "corrupt", + StreamOutcome::Corrupt { + reached: Some(v(2)), + }, + ), + report("done-2", StreamOutcome::Complete { version: v(3) }), + report( + "mismatch", + StreamOutcome::Mismatch { + reached: None, + got: v(9), + }, + ), + ]; + let report = ImportReport::new(streams); + + // streams() preserves first-seen order and full set. + let all_ids: Vec<&[u8]> = report + .streams() + .iter() + .map(|s| s.stream.as_bytes()) + .collect(); + assert_eq!( + all_ids, + [ + b"done-1".as_ref(), + b"corrupt".as_ref(), + b"done-2".as_ref(), + b"mismatch".as_ref() + ] + ); + + // unfinished() is EXACTLY the two non-Complete streams, in order. + let unfinished_ids: Vec<&[u8]> = report.unfinished().map(|s| s.stream.as_bytes()).collect(); + assert_eq!(unfinished_ids, [b"corrupt".as_ref(), b"mismatch".as_ref()]); + + assert!(!report.all_complete()); + } + + #[test] + fn single_incomplete_stream_blocks_all_complete() { + let report = ImportReport::new(vec![report( + "only", + StreamOutcome::Corrupt { reached: None }, + )]); + assert!(!report.all_complete()); + assert_eq!(report.unfinished().count(), 1); + } + + // ── AbortReason / ImportError Display + source ────────────────────────── + + #[test] + fn abort_reason_display_strings_are_exact() { + assert_eq!(AbortReason::Corrupt.to_string(), "block failed checksum"); + assert_eq!( + AbortReason::Mismatch { + expected: v(4), + got: v(7), + } + .to_string(), + "version mismatch (expected 4, got 7)", + ); + } + + #[test] + fn import_error_malformed_display_is_exact() { + let err: ImportError = ImportError::Malformed("bad magic"); + assert_eq!(err.to_string(), "malformed chunk: bad magic"); + } + + #[test] + fn import_error_aborted_display_formats_stream_and_reason() { + let err: ImportError = ImportError::Aborted { + stream: StreamKey::from_slice(b"phone:task-123"), + reason: AbortReason::Mismatch { + expected: v(2), + got: v(5), + }, + }; + // {stream} → StreamKey's Display (the UTF-8 string, unquoted); reason + // via its own Display. + assert_eq!( + err.to_string(), + "chunk aborted at stream phone:task-123: version mismatch (expected 2, got 5)", + ); + } + + #[test] + fn import_error_version_overflow_display_is_exact() { + let err: ImportError = ImportError::VersionOverflow; + assert_eq!(err.to_string(), "version overflow"); + } + + #[test] + fn import_error_store_is_transparent_forwarding_display_and_source() { + // A store error WITH its own source, to prove transparent forwarding + // reaches through to the inner error's source (not just Display). + #[derive(Debug, Error)] + #[error("root cause")] + struct RootCause; + + #[derive(Debug, Error)] + #[error("store failed")] + struct DummyStore(#[source] RootCause); + + let err: ImportError = ImportError::Store(DummyStore(RootCause)); + + // transparent → Display equals the inner store error's Display. + assert_eq!(err.to_string(), "store failed"); + // transparent → source() forwards to the inner error's own source. + let source = err + .source() + .expect("transparent Store must forward a source"); + assert_eq!(source.to_string(), "root cause"); + } + + #[test] + fn non_store_variants_expose_no_error_source() { + // Error-chain shape pin: `Store` is the ONLY source-forwarding variant. + // `Aborted` deliberately interpolates its `reason` into the message + // (not `#[source]`), so it terminates the chain too. + let malformed: ImportError = ImportError::Malformed("x"); + assert!(malformed.source().is_none()); + + let overflow: ImportError = ImportError::VersionOverflow; + assert!(overflow.source().is_none()); + + let aborted: ImportError = ImportError::Aborted { + stream: StreamKey::from_slice(b"s"), + reason: AbortReason::Corrupt, + }; + assert!(aborted.source().is_none()); + } + + // ── AtomicAppend helpers and tests ─────────────────────────────────────── + + fn sk(s: &str) -> StreamKey { + StreamKey::from_slice(s.as_bytes()) + } + + fn pending(ver: u64, payload: &[u8]) -> PendingEnvelope { + pending_envelope(v(ver)) + .event_type("E") + .payload(Bytes::copy_from_slice(payload)) + .build() + .expect("valid envelope") + } + + fn planned(target: &str, expected: Option, versions: &[u64]) -> PlannedAppend { + PlannedAppend { + target: sk(target), + expected_version: expected.and_then(Version::new), + events: versions.iter().map(|n| pending(*n, b"p")).collect(), + } + } + + async fn head_len(store: &nexus_inmemory::InMemoryStore, id: &StreamKey) -> usize { + store + .read_stream(id, Version::INITIAL) + .await + .expect("read opens") + .filter_map(|r| async move { r.ok() }) + .count() + .await + } + + #[tokio::test] + async fn atomic_append_many_commits_all_writes() { + let store = nexus_inmemory::InMemoryStore::new(); + let writes = vec![planned("a", None, &[1, 2]), planned("b", None, &[1])]; + store.atomic_append_many(&writes).await.expect("commits"); + assert_eq!(head_len(&store, &sk("a")).await, 2); + assert_eq!(head_len(&store, &sk("b")).await, 1); + } + + #[tokio::test] + async fn atomic_append_many_rolls_back_all_on_one_conflict() { + let store = nexus_inmemory::InMemoryStore::new(); + // Pre-seed "b" to v1 so the second write (expecting fresh) conflicts. + store + .append(&sk("b"), None, &[pending(1, b"seed")]) + .await + .expect("seed"); + + let writes = vec![ + planned("a", None, &[1, 2]), // would be fine alone + planned("b", None, &[1]), // conflicts: "b" is already at v1 + ]; + let err = store + .atomic_append_many(&writes) + .await + .expect_err("must conflict"); + match err { + AtomicAppendError::Conflict { index, actual } => { + assert_eq!(index, 1); + assert_eq!(actual, Version::new(1)); + } + other => panic!("expected Conflict, got {other:?}"), + } + // Atomicity: NOTHING landed — "a" must still be empty. + assert_eq!(head_len(&store, &sk("a")).await, 0, "rolled back"); + assert_eq!(head_len(&store, &sk("b")).await, 1, "unchanged"); + } + + fn identity_route(origin: &[u8]) -> StreamKey { + StreamKey::from_slice(origin) + } + + async fn versions(store: &nexus_inmemory::InMemoryStore, id: &StreamKey) -> Vec { + store + .read_stream(id, Version::INITIAL) + .await + .expect("read opens") + .map(|r| r.expect("no read error").version().as_u64()) + .collect() + .await + } + + #[tokio::test] + async fn per_stream_clean_multi_stream_all_complete() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![ + section("a", vec![evt(1), evt(2), evt(3)]), + section("b", vec![evt(1), evt(2)]), + ]; + let report = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + + assert!(report.all_complete()); + assert_eq!(report.streams().len(), 2); + assert_eq!(versions(&store, &sk("a")).await, vec![1, 2, 3]); + assert_eq!(versions(&store, &sk("b")).await, vec![1, 2]); + assert_eq!( + report.streams()[0].outcome, + StreamOutcome::Complete { version: v(3) } + ); + } + + #[tokio::test] + async fn per_stream_partial_overlap_is_picky_rejected() { + let store = nexus_inmemory::InMemoryStore::new(); + for n in 1..=3 { + store + .append(&sk("a"), Version::new(n - 1), &[pending(n, b"seed")]) + .await + .expect("seed"); + } + let sections = vec![section("a", vec![evt(2), evt(3), evt(4), evt(5)])]; + let report = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + + assert_eq!( + report.streams()[0].outcome, + StreamOutcome::Mismatch { + reached: None, + got: v(2) + } + ); + assert_eq!(versions(&store, &sk("a")).await, vec![1, 2, 3]); + } + + #[tokio::test] + async fn per_stream_internal_gap_applies_prefix_then_halts() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section("a", vec![evt(1), evt(2), evt(4)])]; + let report = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + + assert_eq!( + report.streams()[0].outcome, + StreamOutcome::Mismatch { + reached: Some(v(2)), + got: v(4) + } + ); + assert_eq!(versions(&store, &sk("a")).await, vec![1, 2], "v4 held back"); + } + + #[tokio::test] + async fn per_stream_mid_section_corrupt_applies_prefix_then_corrupt() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section( + "a", + vec![evt(1), evt(2), ImportBlock::Corrupt, evt(3)], + )]; + let report = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + + assert_eq!( + report.streams()[0].outcome, + StreamOutcome::Corrupt { + reached: Some(v(2)) + } + ); + assert_eq!(versions(&store, &sk("a")).await, vec![1, 2]); + } + + #[tokio::test] + async fn per_stream_first_block_corrupt_reaches_none() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section("a", vec![ImportBlock::Corrupt, evt(1)])]; + let report = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + assert_eq!( + report.streams()[0].outcome, + StreamOutcome::Corrupt { reached: None } + ); + assert_eq!(versions(&store, &sk("a")).await, Vec::::new()); + } + + #[tokio::test] + async fn per_stream_empty_chunk_is_empty_report() { + let store = nexus_inmemory::InMemoryStore::new(); + let report = store + .import(&[], identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + assert!(report.streams().is_empty()); + assert!(report.all_complete()); + } + + #[tokio::test] + async fn per_stream_version_overflow_surfaces_error() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section("a", vec![evt(u64::MAX), evt(1)])]; + let err = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect_err("overflow"); + assert!(matches!(err, ImportError::VersionOverflow)); + } + + // ── FailingAppendStore — test-only store for exercising Store-error arm ── + + /// Test-only store that delegates to `InMemoryStore` but fails `append` + /// (with a Store error) for one target stream — the only way to exercise + /// `import_per_stream`'s `AppendError::Store` arm, which `InMemoryStore` + /// alone cannot produce. + struct FailingAppendStore { + inner: nexus_inmemory::InMemoryStore, + fail_on: String, + } + + impl nexus_store::store::RawEventStore for FailingAppendStore { + type Error = nexus_inmemory::InMemoryStoreError; + type Stream = nexus_inmemory::InMemoryStream; + type AllPosition = nexus_inmemory::InMemoryAllPos; + type AllStream = nexus_inmemory::InMemoryAllStream; + + async fn append( + &self, + id: &StreamKey, + expected_version: Option, + envelopes: &[nexus_store::envelope::PendingEnvelope], + ) -> Result<(), AppendError> { + if id.to_string() == self.fail_on { + return Err(AppendError::Store( + nexus_inmemory::InMemoryStoreError::VersionOverflow, + )); + } + self.inner.append(id, expected_version, envelopes).await + } + + async fn read_stream( + &self, + id: &StreamKey, + from: Version, + ) -> Result { + self.inner.read_stream(id, from).await + } + + async fn read_all( + &self, + from: Option, + ) -> Result { + self.inner.read_all(from).await + } + } + + impl nexus_store::import::AtomicAppend for FailingAppendStore { + async fn atomic_append_many( + &self, + writes: &[nexus_store::import::PlannedAppend], + ) -> Result<(), nexus_store::import::AtomicAppendError> { + self.inner.atomic_append_many(writes).await + } + } + + #[tokio::test] + async fn per_stream_store_error_propagates_and_keeps_prior_commits() { + let store = FailingAppendStore { + inner: nexus_inmemory::InMemoryStore::new(), + fail_on: "b".to_owned(), + }; + let sections = vec![ + section("a", vec![evt(1), evt(2)]), // commits + section("b", vec![evt(1)]), // append fails with Store + ]; + let err = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect_err("store error must surface"); + assert!(matches!(err, ImportError::Store(_))); + // No cross-stream rollback: section "a" stayed committed. + assert_eq!(versions(&store.inner, &sk("a")).await, vec![1, 2]); + } + + // ── EventImporter WholeChunk behavioral tests ──────────────────────────── + + #[tokio::test] + async fn whole_chunk_clean_commits_all_complete() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![ + section("a", vec![evt(1), evt(2)]), + section("b", vec![evt(1)]), + ]; + let report = store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect("import ok"); + assert!(report.all_complete()); + assert_eq!(versions(&store, &sk("a")).await, vec![1, 2]); + assert_eq!(versions(&store, &sk("b")).await, vec![1]); + } + + #[tokio::test] + async fn whole_chunk_corrupt_block_aborts_nothing_lands() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![ + section("a", vec![evt(1), evt(2)]), // would be fine + section("b", vec![evt(1), ImportBlock::Corrupt]), // bad block + ]; + let err = store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect_err("aborts"); + match err { + ImportError::Aborted { stream, reason } => { + assert_eq!(stream, sk("b")); + assert_eq!(reason, AbortReason::Corrupt); + } + other => panic!("expected Aborted, got {other:?}"), + } + // Atomicity: NOTHING landed — even the clean section "a" must be empty. + assert_eq!(versions(&store, &sk("a")).await, Vec::::new()); + assert_eq!(versions(&store, &sk("b")).await, Vec::::new()); + } + + #[tokio::test] + async fn whole_chunk_internal_gap_aborts_mismatch() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section("a", vec![evt(1), evt(2), evt(4)])]; + let err = store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect_err("aborts"); + match err { + ImportError::Aborted { stream, reason } => { + assert_eq!(stream, sk("a")); + assert_eq!( + reason, + AbortReason::Mismatch { + expected: v(3), + got: v(4) + } + ); + } + other => panic!("expected Aborted, got {other:?}"), + } + assert_eq!(versions(&store, &sk("a")).await, Vec::::new()); + } + + #[tokio::test] + async fn whole_chunk_head_conflict_aborts_mismatch() { + let store = nexus_inmemory::InMemoryStore::new(); + for n in 1..=2 { + store + .append(&sk("a"), Version::new(n - 1), &[pending(n, b"seed")]) + .await + .expect("seed"); + } + let sections = vec![section("a", vec![evt(1), evt(2)])]; + let err = store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect_err("aborts"); + match err { + ImportError::Aborted { stream, reason } => { + assert_eq!(stream, sk("a")); + // store head 2 → wanted v3 next; offered v1. + assert_eq!( + reason, + AbortReason::Mismatch { + expected: v(3), + got: v(1) + } + ); + } + other => panic!("expected Aborted, got {other:?}"), + } + assert_eq!(versions(&store, &sk("a")).await, vec![1, 2], "unchanged"); + } + + #[tokio::test] + async fn whole_chunk_first_block_corrupt_aborts() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section("a", vec![ImportBlock::Corrupt])]; + let err = store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect_err("aborts"); + assert!(matches!( + err, + ImportError::Aborted { ref stream, reason: AbortReason::Corrupt } if *stream == sk("a") + )); + } + + #[tokio::test] + async fn whole_chunk_conflict_on_later_section_reports_that_section() { + let store = nexus_inmemory::InMemoryStore::new(); + // Pre-seed "b" to v1 so the SECOND write conflicts (index 1), while "a" + // (index 0) would be fine — exercises index > 0 in the Conflict arm. + store + .append(&sk("b"), None, &[pending(1, b"seed")]) + .await + .expect("seed"); + let sections = vec![ + section("a", vec![evt(1), evt(2)]), + section("b", vec![evt(1)]), + ]; + let err = store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect_err("aborts"); + match err { + ImportError::Aborted { stream, reason } => { + assert_eq!( + stream, + sk("b"), + "must report the conflicting SECOND section" + ); + // "b" head is 1 → wanted v2 next; offered v1. + assert_eq!( + reason, + AbortReason::Mismatch { + expected: v(2), + got: v(1) + } + ); + } + other => panic!("expected Aborted, got {other:?}"), + } + // All-or-nothing: the clean section "a" must NOT have landed. + assert_eq!(versions(&store, &sk("a")).await, Vec::::new()); + assert_eq!(versions(&store, &sk("b")).await, vec![1], "unchanged"); + } + + #[tokio::test] + async fn whole_chunk_empty_section_skipped_others_commit() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section("empty", vec![]), section("b", vec![evt(1), evt(2)])]; + let report = store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect("import ok"); + assert!(report.all_complete()); + // The empty section produces NO report entry; only "b". + assert_eq!(report.streams().len(), 1); + assert_eq!(report.streams()[0].stream, sk("b")); + assert_eq!(versions(&store, &sk("b")).await, vec![1, 2]); + } + + // ── Defensive boundary: non-injective route (two origins → one target) ──── + + #[tokio::test] + async fn whole_chunk_non_injective_route_aborts_no_corruption() { + // A non-injective route maps BOTH origin sections to the same target. + // WholeChunk must abort with nothing landed — never a [1,2,1,2] stream. + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![ + section("o1", vec![evt(1), evt(2)]), + section("o2", vec![evt(1), evt(2)]), + ]; + let to_same = |_origin: &[u8]| sk("T"); + let err = store + .import(§ions, to_same, Atomicity::WholeChunk) + .await + .expect_err("non-injective route must abort"); + assert!(matches!(err, ImportError::Aborted { .. })); + // All-or-nothing + no corruption: T is empty, definitely not [1,2,1,2]. + assert_eq!(versions(&store, &sk("T")).await, Vec::::new()); + } + + #[tokio::test] + async fn per_stream_non_injective_route_second_rejected_no_corruption() { + // PerStream: the first section commits the target; the second (same + // target) is picky-rejected — the stream is [1,2], never [1,2,1,2]. + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![ + section("o1", vec![evt(1), evt(2)]), + section("o2", vec![evt(1), evt(2)]), + ]; + let to_same = |_origin: &[u8]| sk("T"); + let report = store + .import(§ions, to_same, Atomicity::PerStream) + .await + .expect("import ok"); + assert_eq!( + versions(&store, &sk("T")).await, + vec![1, 2], + "no [1,2,1,2] corruption — second section rejected, not appended" + ); + assert_eq!( + report.streams()[0].outcome, + StreamOutcome::Complete { version: v(2) } + ); + assert_eq!( + report.streams()[1].outcome, + StreamOutcome::Mismatch { + reached: None, + got: v(1) + } + ); + } + + // ── #247: the Store handle is the front door — import, no .raw() ───────── + + #[tokio::test] + async fn store_handle_imports_without_raw() { + // Substitutable: generic `EventImporter`/`AtomicAppend`-bounded code + // accepts the handle directly (the structural win of #247). + fn assert_importer(_: &I) {} + fn assert_atomic(_: &A) {} + + // The handle itself imports and atomic-appends — never `.raw()`. + let store = Store::new(nexus_inmemory::InMemoryStore::new()); + let sections = vec![section("a", vec![evt(1), evt(2)])]; + let report = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import via the handle"); + assert!(report.all_complete()); + assert_eq!( + report.streams()[0].outcome, + StreamOutcome::Complete { version: v(2) } + ); + store + .atomic_append_many(&[planned("b", None, &[1])]) + .await + .expect("atomic append via the handle"); + + // The generic bounds above accept the handle directly (the #247 win). + assert_importer(&store); + assert_atomic(&store); + } + + // ── Round-trip + idempotency (Card-1 export ↔ Card-2 import) ──────────── + + async fn export_section(store: &nexus_inmemory::InMemoryStore, origin: &str) -> StreamSection { + let blocks = store + .export_stream(&sk(origin), Version::INITIAL) + .await + .expect("export opens") + .map(|r| ImportBlock::Event(r.expect("no read error"))) + .collect::>() + .await; + section(origin, blocks) + } + + #[tokio::test] + async fn export_then_import_round_trips_byte_equal_modulo_all_position() { + // Source store with two streams. + let source = nexus_inmemory::InMemoryStore::new(); + for n in 1..=3 { + source + .append(&sk("acct-1"), Version::new(n - 1), &[pending(n, b"a")]) + .await + .expect("seed a"); + } + source + .append(&sk("acct-2"), None, &[pending(1, b"b")]) + .await + .expect("seed b"); + + let sections = vec![ + export_section(&source, "acct-1").await, + export_section(&source, "acct-2").await, + ]; + + // Import into a FRESH store, identity routing. + let target = nexus_inmemory::InMemoryStore::new(); + // Pre-seed a warmup event so the target's `$all` counter is already + // advanced — imported events must get FRESH positions (2..) rather than + // copying the source's (which started at 1), making the restamp + // observable on the `$all` read. + target + .append(&sk("warmup"), None, &[pending(1, b"warmup")]) + .await + .expect("warmup seed"); + let report = target + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + assert!(report.all_complete()); + + // Per-stream events are byte-equal (a per-stream event carries no global + // position): compare version/type/payload/metadata/schema verbatim. + for origin in ["acct-1", "acct-2"] { + let src: Vec = source + .read_stream(&sk(origin), Version::INITIAL) + .await + .expect("read") + .map(|r| r.expect("ok")) + .collect() + .await; + let dst: Vec = target + .read_stream(&sk(origin), Version::INITIAL) + .await + .expect("read") + .map(|r| r.expect("ok")) + .collect() + .await; + assert_eq!(src.len(), dst.len(), "{origin} length"); + for (s, d) in src.iter().zip(dst.iter()) { + assert_eq!(s.version(), d.version(), "{origin} version"); + assert_eq!(s.event_type(), d.event_type(), "{origin} type"); + assert_eq!(s.payload(), d.payload(), "{origin} payload"); + assert_eq!(s.metadata(), d.metadata(), "{origin} metadata"); + assert_eq!(s.schema_version(), d.schema_version(), "{origin} schema"); + } + } + + // Restamp observable on `$all`: the target re-stamped fresh positions, so + // its `$all` order is the warmup (1) then the imported events (2..=5), + // strictly increasing — NOT the source's positions copied verbatim. + let target_positions: Vec = target + .read_all(None) + .await + .expect("read_all") + .map(|r| r.expect("ok").0.as_u64()) + .collect() + .await; + assert_eq!( + target_positions, + vec![1, 2, 3, 4, 5], + "import must re-stamp fresh $all positions, not copy the source's", + ); + } + + #[tokio::test] + async fn reimport_same_chunk_is_idempotent_per_stream() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section("a", vec![evt(1), evt(2), evt(3)])]; + + let first = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + assert!(first.all_complete()); + + let second = store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok"); + assert_eq!( + second.streams()[0].outcome, + StreamOutcome::Mismatch { + reached: None, + got: v(1) + } + ); + assert_eq!(versions(&store, &sk("a")).await, vec![1, 2, 3], "no dupes"); + } + + #[tokio::test] + async fn reimport_same_chunk_whole_chunk_aborts() { + let store = nexus_inmemory::InMemoryStore::new(); + let sections = vec![section("a", vec![evt(1), evt(2)])]; + store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect("first import ok"); + let err = store + .import(§ions, identity_route, Atomicity::WholeChunk) + .await + .expect_err("re-import aborts"); + match err { + ImportError::Aborted { stream, reason } => { + assert_eq!(stream, sk("a")); + assert_eq!( + reason, + AbortReason::Mismatch { + expected: v(3), + got: v(1) + } + ); + } + other => panic!("expected Aborted, got {other:?}"), + } + assert_eq!(versions(&store, &sk("a")).await, vec![1, 2], "no dupes"); + } + + // ── Linearizability: concurrent import vs direct writer (CLAUDE rule 7 §4) ─ + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn per_stream_import_concurrent_with_writer_surfaces_conflict_no_torn_stream() { + // Two actors race to populate the SAME fresh target stream from v1: + // a direct writer (append v1..=N) and an importer (a v1..=N section). + // Exactly one wins the v1 slot; the other must surface a Mismatch (a + // conflict, never a silent retry — CLAUDE rule 5). The stream must end + // as a gapless prefix from v1, never torn. + let store = Arc::new(nexus_inmemory::InMemoryStore::new()); + let n = 50u64; + let barrier = Arc::new(Barrier::new(2)); + + let writer_store = Arc::clone(&store); + let writer_barrier = Arc::clone(&barrier); + let writer = tokio::spawn(async move { + writer_barrier.wait().await; + for vn in 1..=n { + // Conflicts are expected once the importer wins; ignore them. + let _ = writer_store + .append(&sk("race"), Version::new(vn - 1), &[pending(vn, b"w")]) + .await; + } + }); + + let importer_store = Arc::clone(&store); + let importer_barrier = Arc::clone(&barrier); + let importer = tokio::spawn(async move { + importer_barrier.wait().await; + let blocks: Vec = (1..=n).map(evt).collect(); + let sections = vec![section("race", blocks)]; + importer_store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import returns Ok (conflicts are per-stream outcomes)") + }); + + writer.await.expect("writer task"); + let report = importer.await.expect("importer task"); + + // The importer's outcome is either Complete (it won v1) or Mismatch + // (the writer won) — never a partial/torn application. + let outcome = report.streams().first().map(|s| s.outcome); + match outcome { + Some( + StreamOutcome::Complete { .. } | StreamOutcome::Mismatch { reached: None, .. }, + ) => {} + other => panic!("unexpected importer outcome: {other:?}"), + } + + // Whatever the interleaving, the final stream is a gapless prefix from 1. + let final_versions = versions(&store, &sk("race")).await; + for (expected, got) in (1u64..).zip(final_versions.iter()) { + assert_eq!(*got, expected, "stream must be a gapless prefix from 1"); + } + assert!(!final_versions.is_empty(), "some events landed"); + } + + // ── State-machine property test: PerStream import never gaps ──────────── + + // Model-based: a sequence of single-stream PerStream imports against one + // target. The model tracks the stream's next-expected version; each import + // offers a first version + a contiguous block count. Invariants: the + // reported outcome matches the model's picky rule, the stream never develops + // a gap, and what landed matches the report. + #[derive(Debug, Clone)] + enum Cmd { + // Offer `count` contiguous events starting at `first`. + Import { first: u64, count: u64 }, + } + + fn cmd_strategy() -> impl Strategy { + // Boundary-inclusive: first ∈ {1,2,3}, count ∈ {1,2,3}. + (prop_oneof![Just(1u64), Just(2u64), Just(3u64)], 1u64..=3) + .prop_map(|(first, count)| Cmd::Import { first, count }) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + #[test] + fn state_machine_per_stream_never_gaps_and_report_matches_model( + cmds in proptest::collection::vec(cmd_strategy(), 1..12), + ) { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime"); + rt.block_on(async { + let store = nexus_inmemory::InMemoryStore::new(); + let id = sk("m"); + // Model: next expected version (1 = fresh). + let mut next: u64 = 1; + + for cmd in cmds { + let Cmd::Import { first, count } = cmd; + let blocks: Vec = + (first..first + count).map(evt).collect(); + let sections = vec![section("m", blocks)]; + let report = { + store + .import(§ions, identity_route, Atomicity::PerStream) + .await + .expect("import ok") + }; + let outcome = report.streams()[0].outcome; + + if first == next { + // Picky check passes: the whole contiguous run applies. + let landed_last = first + count - 1; + prop_assert_eq!( + outcome, + StreamOutcome::Complete { version: v(landed_last) } + ); + next = landed_last + 1; + } else { + // Picky reject: nothing applied, model unchanged. + prop_assert_eq!( + outcome, + StreamOutcome::Mismatch { reached: None, got: v(first) } + ); + } + + // Invariant: the stream is ALWAYS a gapless prefix 1..next-1. + let got = versions(&store, &id).await; + let expected: Vec = (1..next).collect(); + prop_assert_eq!(got, expected); + } + Ok(()) + })?; + } + } +} diff --git a/crates/nexus-store/tests/integration_tests.rs b/crates/nexus-store/tests/integration_tests.rs index bdc58477..5597dd7c 100644 --- a/crates/nexus-store/tests/integration_tests.rs +++ b/crates/nexus-store/tests/integration_tests.rs @@ -19,9 +19,9 @@ use futures::StreamExt; use nexus::Version; +use nexus_inmemory::{InMemoryStore, InMemoryStream}; use nexus_store::pending_envelope; use nexus_store::store::RawEventStore; -use nexus_store::testing::{InMemoryStore, InMemoryStream}; // ============================================================================= // Helpers diff --git a/crates/nexus-store/tests/phase_subscription_tests.rs b/crates/nexus-store/tests/phase_subscription_tests.rs index 8f1229d1..0e48d56f 100644 --- a/crates/nexus-store/tests/phase_subscription_tests.rs +++ b/crates/nexus-store/tests/phase_subscription_tests.rs @@ -1,4 +1,4 @@ -#![cfg(all(feature = "testing", feature = "json"))] +#![cfg(feature = "json")] #![allow(clippy::unwrap_used, reason = "tests")] #![allow(clippy::expect_used, reason = "tests")] #![allow(clippy::panic, reason = "tests")] @@ -8,8 +8,8 @@ use std::time::Duration; use futures::{Stream, StreamExt}; use nexus::{DomainEvent, Message, Version}; +use nexus_inmemory::InMemoryStore; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::{ DecodeStreamError, Decoded, DecodedStreamExt, Encode, JsonCodec, Step, StepStreamExt, Store, StreamKey, Subscription, pending_envelope, diff --git a/crates/nexus-store/tests/projection_stepper_tests.rs b/crates/nexus-store/tests/projection_stepper_tests.rs new file mode 100644 index 00000000..a2c7e7f3 --- /dev/null +++ b/crates/nexus-store/tests/projection_stepper_tests.rs @@ -0,0 +1,373 @@ +//! Relocated inline test mod of `src/projection.rs` (nexus-inmemory is a +//! dev-dependency; type unification with it requires an integration test). + +#![cfg(feature = "projection")] + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test module" + )] + + use core::num::{NonZeroU32, NonZeroU64}; + + use nexus::{DomainEvent, Message, Version, version}; + + use nexus_inmemory::InMemorySnapshotStore; + use nexus_store::decoded::Decoded; + use nexus_store::projection::{Projection, ProjectionError, Projector}; + use nexus_store::state::{EveryNEvents, Hydrated, SnapshotStore}; + + // ── fixtures ──────────────────────────────────────────────────────────── + + #[derive(Debug, Clone, Hash, PartialEq, Eq)] + struct TestId(&'static str); + impl core::fmt::Display for TestId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.0) + } + } + impl AsRef<[u8]> for TestId { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } + } + + #[derive(Debug, Clone, PartialEq)] + struct CountState { + count: u64, + total: u64, + } + + #[derive(Debug)] + enum TestEvent { + Added(u64), + Removed(u64), + } + impl Message for TestEvent {} + impl DomainEvent for TestEvent { + fn name(&self) -> &'static str { + match self { + Self::Added(_) => "Added", + Self::Removed(_) => "Removed", + } + } + } + + #[derive(Debug, thiserror::Error)] + #[error("projection overflow")] + struct TestProjectionError; + + struct CountingProjector; + impl Projector for CountingProjector { + type Event = TestEvent; + type State = CountState; + type Error = TestProjectionError; + + fn initial(&self) -> CountState { + CountState { count: 0, total: 0 } + } + fn apply( + &self, + state: CountState, + event: &TestEvent, + ) -> Result { + let count = state.count.checked_add(1).ok_or(TestProjectionError)?; + let total = match event { + TestEvent::Added(n) => state.total.checked_add(*n).ok_or(TestProjectionError)?, + TestEvent::Removed(n) => state.total.checked_sub(*n).ok_or(TestProjectionError)?, + }; + Ok(CountState { count, total }) + } + } + + /// Build a `Decoded` event at a given version, the way `.decoded()` would. + const fn decoded(event: TestEvent, ver: u64) -> Decoded { + Decoded { + event, + version: Version::new(ver).expect("nonzero version"), + metadata: None, + } + } + + fn store() -> InMemorySnapshotStore { + InMemorySnapshotStore::new() + } + + // ── 1. Sequence/Protocol: load → advance×n commits and folds ──────────── + + #[tokio::test] + async fn advance_folds_and_commits_each_event_when_trigger_always_fires() { + let ss = store(); + let id = TestId("s"); + let (mut p, mut state) = Projection::load( + id.clone(), + CountingProjector, + EveryNEvents(NonZeroU64::MIN), + &ss, + NonZeroU32::MIN, + ) + .await + .unwrap(); + + assert_eq!(p.checkpoint(), None); + state = p + .advance(state, decoded(TestEvent::Added(10), 1)) + .await + .unwrap(); + state = p + .advance(state, decoded(TestEvent::Added(20), 2)) + .await + .unwrap(); + state = p + .advance(state, decoded(TestEvent::Added(30), 3)) + .await + .unwrap(); + + assert_eq!(p.checkpoint(), Some(version!(3))); + assert_eq!( + state, + CountState { + count: 3, + total: 60 + } + ); + + // Persisted together, atomically. + let (pos, st) = ss + .hydrate(&id, NonZeroU32::MIN) + .await + .unwrap() + .into_found() + .unwrap(); + assert_eq!(pos, version!(3)); + assert_eq!( + st, + CountState { + count: 3, + total: 60 + } + ); + } + + // ── 2. Lifecycle: commit → reload → resume from checkpoint ─────────────── + + #[tokio::test] + async fn load_resumes_state_and_checkpoint_from_snapshot() { + let ss = store(); + let id = TestId("s"); + + { + let (mut p, mut state) = Projection::load( + id.clone(), + CountingProjector, + EveryNEvents(NonZeroU64::MIN), + &ss, + NonZeroU32::MIN, + ) + .await + .unwrap(); + state = p + .advance(state, decoded(TestEvent::Added(10), 1)) + .await + .unwrap(); + state = p + .advance(state, decoded(TestEvent::Added(20), 2)) + .await + .unwrap(); + let _ = p + .advance(state, decoded(TestEvent::Added(30), 3)) + .await + .unwrap(); + } + + // Fresh stepper over the same snapshot store: state + checkpoint restored. + let (mut p2, state2) = Projection::load( + id.clone(), + CountingProjector, + EveryNEvents(NonZeroU64::MIN), + &ss, + NonZeroU32::MIN, + ) + .await + .unwrap(); + assert_eq!(p2.checkpoint(), Some(version!(3))); + assert_eq!( + state2, + CountState { + count: 3, + total: 60 + } + ); + + // Resume: the next event folds onto the restored state. + let resumed = p2 + .advance(state2, decoded(TestEvent::Added(40), 4)) + .await + .unwrap(); + assert_eq!(p2.checkpoint(), Some(version!(4))); + assert_eq!( + resumed, + CountState { + count: 4, + total: 100 + } + ); + } + + // ── 3. Defensive boundary: a failing apply surfaces as ::Apply ─────────── + + #[tokio::test] + async fn advance_surfaces_projector_apply_error() { + let ss = store(); + let (mut p, state) = Projection::load( + TestId("s"), + CountingProjector, + EveryNEvents(NonZeroU64::MIN), + &ss, + NonZeroU32::MIN, + ) + .await + .unwrap(); + + // Removed(1) from total 0 underflows in the projector. + let err = p + .advance(state, decoded(TestEvent::Removed(1), 1)) + .await + .unwrap_err(); + assert!( + matches!(err, ProjectionError::Apply(_)), + "expected Apply, got {err:?}" + ); + // Nothing was committed on the failed fold. + assert_eq!(p.checkpoint(), None); + } + + // ── flush semantics: tail is committed once on shutdown ────────────────── + + #[tokio::test] + async fn flush_commits_folded_but_unpersisted_tail() { + let ss = store(); + let id = TestId("s"); + // Trigger never fires (bucket of 100) — only flush persists. + let (mut p, mut state) = Projection::load( + id.clone(), + CountingProjector, + EveryNEvents(NonZeroU64::new(100).unwrap()), + &ss, + NonZeroU32::MIN, + ) + .await + .unwrap(); + + state = p + .advance(state, decoded(TestEvent::Added(10), 1)) + .await + .unwrap(); + state = p + .advance(state, decoded(TestEvent::Added(20), 2)) + .await + .unwrap(); + assert_eq!(p.checkpoint(), None, "trigger must not have fired"); + assert_eq!( + ss.hydrate(&id, NonZeroU32::MIN).await.unwrap(), + Hydrated::Absent + ); + + p.flush(&state).await.unwrap(); + assert_eq!(p.checkpoint(), Some(version!(2))); + let (pos, st) = ss + .hydrate(&id, NonZeroU32::MIN) + .await + .unwrap() + .into_found() + .unwrap(); + assert_eq!(pos, version!(2)); + assert_eq!( + st, + CountState { + count: 2, + total: 30 + } + ); + } + + #[tokio::test] + async fn flush_is_a_noop_when_nothing_is_pending() { + let ss = store(); + let id = TestId("s"); + let (mut p, state) = Projection::load( + id.clone(), + CountingProjector, + EveryNEvents(NonZeroU64::MIN), + &ss, + NonZeroU32::MIN, + ) + .await + .unwrap(); + + // Never advanced: flush must not write a spurious snapshot. + p.flush(&state).await.unwrap(); + assert_eq!(p.checkpoint(), None); + assert_eq!( + ss.hydrate(&id, NonZeroU32::MIN).await.unwrap(), + Hydrated::Absent + ); + } + + // ── defensive: a schema-mismatched snapshot starts fresh, but flags a rebuild ─ + + #[tokio::test] + async fn load_stale_schema_starts_from_initial_and_signals_rebuild() { + let ss = store(); + let id = TestId("s"); + // Pre-commit a v1 snapshot. + ss.commit( + &id, + NonZeroU32::MIN, + version!(5), + &CountState { + count: 99, + total: 999, + }, + ) + .await + .unwrap(); + + // Load with schema v2 — the v1 snapshot must not be restored... + let (p, state) = Projection::load( + id, + CountingProjector, + EveryNEvents(NonZeroU64::MIN), + &ss, + NonZeroU32::new(2).unwrap(), + ) + .await + .unwrap(); + assert_eq!(p.checkpoint(), None); + assert_eq!(state, CountState { count: 0, total: 0 }); + // ...but the rebuild is *visible*: a host can tell this apart from a + // fresh start (which reports `None`) — the whole point of the Stale + // signal. The invalidated schema version (v1) is reported. + assert_eq!(p.rebuilding_from(), Some(NonZeroU32::MIN)); + } + + #[tokio::test] + async fn load_fresh_does_not_signal_rebuild() { + let ss = store(); + let (p, _state) = Projection::load( + TestId("s"), + CountingProjector, + EveryNEvents(NonZeroU64::MIN), + &ss, + NonZeroU32::MIN, + ) + .await + .unwrap(); + // A genuinely-new projection is not a rebuild. + assert_eq!(p.rebuilding_from(), None); + } +} diff --git a/crates/nexus-store/tests/projection_tests.rs b/crates/nexus-store/tests/projection_tests.rs index c542ceb5..587b7b36 100644 --- a/crates/nexus-store/tests/projection_tests.rs +++ b/crates/nexus-store/tests/projection_tests.rs @@ -121,10 +121,9 @@ fn proj_after_event_types_does_not_trigger_on_empty_events() { // ── InMemorySnapshotStore ──────────────────────────────────────── -#[cfg(feature = "testing")] mod in_memory_tests { use super::*; - use nexus_store::state::InMemorySnapshotStore; + use nexus_inmemory::InMemorySnapshotStore; #[tokio::test] async fn hydrate_returns_none_when_empty() { diff --git a/crates/nexus-store/tests/property_tests.rs b/crates/nexus-store/tests/property_tests.rs index 062cb407..dff518b9 100644 --- a/crates/nexus-store/tests/property_tests.rs +++ b/crates/nexus-store/tests/property_tests.rs @@ -33,11 +33,11 @@ use std::convert::Infallible; use futures::StreamExt; use nexus::Version; +use nexus_inmemory::InMemoryStore; use nexus_store::StreamKey; use nexus_store::envelope::PendingEnvelope; use nexus_store::pending_envelope; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use proptest::prelude::*; diff --git a/crates/nexus-store/tests/repository_qa_tests.rs b/crates/nexus-store/tests/repository_qa_tests.rs index e9d257c3..fc9846c4 100644 --- a/crates/nexus-store/tests/repository_qa_tests.rs +++ b/crates/nexus-store/tests/repository_qa_tests.rs @@ -45,13 +45,13 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use nexus::*; +use nexus_inmemory::InMemoryStore; use nexus_store::Repository; use nexus_store::Store; use nexus_store::codec::{Decode, Encode}; use nexus_store::envelope::PersistedEnvelope; use nexus_store::error::StoreError; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::upcasting::EventMorsel; use proptest::prelude::*; diff --git a/crates/nexus-store/tests/saga_repository_tests.rs b/crates/nexus-store/tests/saga_repository_tests.rs index 0e7097af..515be363 100644 --- a/crates/nexus-store/tests/saga_repository_tests.rs +++ b/crates/nexus-store/tests/saga_repository_tests.rs @@ -21,7 +21,7 @@ use futures::future::join_all; use nexus::{ Aggregate, AggregateRoot, AggregateState, DomainEvent, Events, Message, React, Saga, Version, }; -use nexus_store::testing::InMemoryStore; +use nexus_inmemory::InMemoryStore; use nexus_store::{ Decode, Encode, PersistedEnvelope, Reaction, Repository, SagaError, SagaRepository, Store, }; diff --git a/crates/nexus-store/tests/security_tests.rs b/crates/nexus-store/tests/security_tests.rs index 55599b1e..a0f2efdd 100644 --- a/crates/nexus-store/tests/security_tests.rs +++ b/crates/nexus-store/tests/security_tests.rs @@ -28,11 +28,11 @@ use futures::StreamExt; use nexus::{ErrorId, Version}; -use nexus_store::InMemoryStoreError; +use nexus_inmemory::InMemoryStore; +use nexus_inmemory::InMemoryStoreError; use nexus_store::error::StoreError; use nexus_store::pending_envelope; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; /// Concrete `StoreError` for tests using `InMemoryStore` with no codec/upcaster. type TestStoreError = StoreError; diff --git a/crates/nexus-store/tests/serde_codec_tests.rs b/crates/nexus-store/tests/serde_codec_tests.rs index 80787922..c3b8d83e 100644 --- a/crates/nexus-store/tests/serde_codec_tests.rs +++ b/crates/nexus-store/tests/serde_codec_tests.rs @@ -166,12 +166,11 @@ fn json_codec_is_constructible_via_new() { // Integration test — full EventStore round-trip // ============================================================================= -#[cfg(feature = "testing")] mod integration { use super::*; + use nexus_inmemory::InMemoryStore; use nexus_store::Repository; use nexus_store::Store; - use nexus_store::testing::InMemoryStore; #[tokio::test] async fn json_codec_works_with_event_store() { diff --git a/crates/nexus-store/tests/snapshot_integration_tests.rs b/crates/nexus-store/tests/snapshot_integration_tests.rs index 494b9d9a..c064e716 100644 --- a/crates/nexus-store/tests/snapshot_integration_tests.rs +++ b/crates/nexus-store/tests/snapshot_integration_tests.rs @@ -1,4 +1,4 @@ -#![cfg(all(feature = "snapshot", feature = "json", feature = "testing"))] +#![cfg(all(feature = "snapshot", feature = "json"))] #![allow( clippy::unwrap_used, clippy::expect_used, @@ -25,11 +25,9 @@ fn sv2() -> NonZeroU32 { } use nexus::*; +use nexus_inmemory::{InMemorySnapshotStore, InMemoryStore}; use nexus_store::Store; -use nexus_store::state::{ - AfterEventTypes, EveryNEvents, Hydrated, InMemorySnapshotStore, PersistTrigger, SnapshotStore, -}; -use nexus_store::testing::InMemoryStore; +use nexus_store::state::{AfterEventTypes, EveryNEvents, Hydrated, PersistTrigger, SnapshotStore}; use nexus_store::{Repository, Snapshotting}; // ── Test domain ──────────────────────────────────────────────────── diff --git a/crates/nexus-store/tests/snapshot_tests.rs b/crates/nexus-store/tests/snapshot_tests.rs index a357e2ee..0a49d763 100644 --- a/crates/nexus-store/tests/snapshot_tests.rs +++ b/crates/nexus-store/tests/snapshot_tests.rs @@ -127,10 +127,9 @@ fn after_event_types_does_not_trigger_on_empty_events() { // ── InMemorySnapshotStore ──────────────────────────────────────── -#[cfg(feature = "testing")] mod in_memory_tests { use super::*; - use nexus_store::state::InMemorySnapshotStore; + use nexus_inmemory::InMemorySnapshotStore; #[tokio::test] async fn hydrate_returns_none_when_empty() { @@ -230,12 +229,12 @@ mod in_memory_tests { // ── Builder integration ───────────────────────────────────────────── -#[cfg(all(feature = "testing", feature = "snapshot-json"))] +#[cfg(feature = "snapshot-json")] mod builder_tests { use super::*; + use nexus_inmemory::InMemorySnapshotStore; + use nexus_inmemory::InMemoryStore; use nexus_store::Store; - use nexus_store::state::InMemorySnapshotStore; - use nexus_store::testing::InMemoryStore; // A phantom aggregate to name at `repository::()`. These tests only check // that the builder *chain* typechecks; `.build()` does not bound `A`, so a diff --git a/crates/nexus-store/tests/step_stream_ext_tests.rs b/crates/nexus-store/tests/step_stream_ext_tests.rs index d880d6b1..15161989 100644 --- a/crates/nexus-store/tests/step_stream_ext_tests.rs +++ b/crates/nexus-store/tests/step_stream_ext_tests.rs @@ -8,7 +8,7 @@ //! items at arbitrary positions. End-to-end behaviour over a real `subscribe` //! lives in `phase_subscription_tests`. -#![cfg(all(feature = "testing", feature = "json"))] +#![cfg(feature = "json")] #![allow(clippy::unwrap_used, reason = "tests")] #![allow(clippy::expect_used, reason = "tests")] #![allow(clippy::panic, reason = "tests")] @@ -20,8 +20,8 @@ use futures::{StreamExt, stream}; use nexus::{DomainEvent, Message, Version}; +use nexus_inmemory::InMemoryStore; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::{ DecodeStreamError, Decoded, Encode, JsonCodec, PersistedEnvelope, Step, StepStreamExt, Store, StreamKey, pending_envelope, diff --git a/crates/nexus-store/tests/subscription_tests.rs b/crates/nexus-store/tests/subscription_tests.rs index 95d6be23..9a4fd606 100644 --- a/crates/nexus-store/tests/subscription_tests.rs +++ b/crates/nexus-store/tests/subscription_tests.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "testing")] +#![cfg(feature = "subscription")] #![allow(clippy::unwrap_used, reason = "tests")] #![allow(clippy::expect_used, reason = "tests")] #![allow(clippy::panic, reason = "tests")] @@ -7,8 +7,8 @@ use std::time::Duration; use futures::StreamExt; use nexus::Version; +use nexus_inmemory::InMemoryStore; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::{StepStreamExt, Store, Subscription, pending_envelope}; use tokio::time::timeout; diff --git a/crates/nexus-store/tests/wire_alignment_tests.rs b/crates/nexus-store/tests/wire_alignment_tests.rs index 52619e07..d696c242 100644 --- a/crates/nexus-store/tests/wire_alignment_tests.rs +++ b/crates/nexus-store/tests/wire_alignment_tests.rs @@ -7,7 +7,6 @@ //! invariant that `BytemuckCodec` and `RkyvCodec` (and any other //! `align_to`-based zero-copy decoder) rely on. -#![cfg(feature = "testing")] #![allow(clippy::unwrap_used, reason = "tests")] #![allow(clippy::expect_used, reason = "tests")] #![allow(clippy::cast_possible_truncation, reason = "tests")] @@ -25,8 +24,8 @@ use bytes::Bytes; use futures::StreamExt; use nexus::Version; +use nexus_inmemory::InMemoryStore; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::{PendingEnvelope, StreamKey, pending_envelope}; const ALIGN: usize = 16; diff --git a/crates/nexus-wake/Cargo.toml b/crates/nexus-wake/Cargo.toml new file mode 100644 index 00000000..cbaffe01 --- /dev/null +++ b/crates/nexus-wake/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "nexus-wake" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "In-process subscription wake registry (StreamNotifiers) for nexus-store adapters" +readme = "../../README.md" +keywords = ["event-sourcing", "subscription", "wake", "notify"] +categories = ["data-structures", "asynchronous"] + +[dependencies] +foldhash = { workspace = true } +nexus-store = { version = "0.1.0", path = "../nexus-store", features = ["subscription"] } +parking_lot = { workspace = true } +thiserror = { workspace = true, features = ["std"] } +tokio = { workspace = true, features = ["sync"] } +workspace-hack = { version = "0.1", path = "../workspace-hack" } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } + +[lints] +workspace = true diff --git a/crates/nexus-store/src/notify.rs b/crates/nexus-wake/src/lib.rs similarity index 90% rename from crates/nexus-store/src/notify.rs rename to crates/nexus-wake/src/lib.rs index 2011c44c..257de642 100644 --- a/crates/nexus-store/src/notify.rs +++ b/crates/nexus-wake/src/lib.rs @@ -77,7 +77,7 @@ use thiserror::Error; use tokio::sync::Notify; use tokio::sync::watch; -use crate::wake::{WakeRegistration, WakeSource}; +use nexus_store::wake::{WakeRegistration, WakeSource}; /// Errors produced by [`StreamNotifiers`]. #[derive(Debug, Error)] @@ -793,3 +793,96 @@ mod tests { assert_eq!(reg.generation(b"never"), 0); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "test code")] +mod wake_source_contract_tests { + use super::*; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::Barrier; + use tokio::time::timeout; + + const MUST_WAKE: Duration = Duration::from_secs(5); + + /// Ported lost-wakeup test: a registration armed before a concurrent wake + /// must never miss it. Repeated to shake out scheduling races. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn armed_wait_never_loses_a_concurrent_wake() { + for _ in 0..50 { + let reg = StreamNotifiers::new(); + let registration = reg.register(Some(b"k")).unwrap(); + let wait = registration.arm(); // armed BEFORE the race + let start = Arc::new(Barrier::new(2)); + + let start_prod = Arc::clone(&start); + let reg_prod = Arc::clone(®); + let prod = tokio::spawn(async move { + start_prod.wait().await; + reg_prod.wake(b"k"); + }); + + start.wait().await; + timeout(MUST_WAKE, wait) + .await + .expect("an armed wait must not lose a concurrent wake"); + prod.await.unwrap(); + } + } + + /// $all registration wakes on any stream's wake. + #[tokio::test] + async fn all_registration_wakes_on_any_stream() { + let reg = StreamNotifiers::new(); + let registration = reg.register(None).unwrap(); + let wait = registration.arm(); + reg.wake(b"any-stream"); + timeout(MUST_WAKE, wait) + .await + .expect("$all must wake on any stream wake"); + } + + /// Drives the wake purely through the `WakeSource` trait surface (a generic + /// bound, the way the subscription loop uses it): register, arm, then call + /// the *trait* `wake`. Proves the trait method is not the recursive shadow + /// of the inherent one (a recursion would hang and time out here) and that + /// a per-stream wake reaches both a per-stream and an `$all` registration. + #[tokio::test] + async fn trait_wake_routes_to_stream_and_all() { + async fn arm_and_wake(src: &W) { + let per_stream = src.register(Some(b"k")).unwrap(); + let all = src.register(None).unwrap(); + let wait_stream = per_stream.arm(); + let wait_all = all.arm(); + // Trait-dispatched wake (not the inherent method). + WakeSource::wake(src, b"k"); + timeout(MUST_WAKE, wait_stream) + .await + .expect("trait wake must rouse the per-stream registration"); + timeout(MUST_WAKE, wait_all) + .await + .expect("trait wake must rouse the $all registration"); + } + + let reg = StreamNotifiers::new(); + arm_and_wake(reg.as_ref()).await; + } + + /// Dropping a per-stream `WakeReg` reaps the entry through the guard chain. + #[tokio::test] + async fn dropping_registration_reaps_entry() { + let reg = StreamNotifiers::new(); + let registration = reg.register(Some(b"s")).unwrap(); + assert_eq!( + reg.active_streams(), + 1, + "register(Some) must create one entry" + ); + drop(registration); + assert_eq!( + reg.active_streams(), + 0, + "dropping the registration must reap the entry" + ); + } +} diff --git a/examples/projection-tokio/Cargo.toml b/examples/projection-tokio/Cargo.toml index 6a57dffb..ce364e54 100644 --- a/examples/projection-tokio/Cargo.toml +++ b/examples/projection-tokio/Cargo.toml @@ -8,7 +8,8 @@ publish = false bytes.workspace = true futures.workspace = true nexus = { path = "../../crates/nexus", features = ["derive"] } -nexus-store = { path = "../../crates/nexus-store", features = ["projection", "testing"] } +nexus-inmemory = { path = "../../crates/nexus-inmemory" } +nexus-store = { path = "../../crates/nexus-store", features = ["projection", "subscription"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } workspace-hack = { version = "0.1", path = "../../crates/workspace-hack" } diff --git a/examples/projection-tokio/tests/loop_tests.rs b/examples/projection-tokio/tests/loop_tests.rs index a29a0e6b..3d355d8e 100644 --- a/examples/projection-tokio/tests/loop_tests.rs +++ b/examples/projection-tokio/tests/loop_tests.rs @@ -41,10 +41,11 @@ use std::num::{NonZeroU32, NonZeroU64}; use futures::StreamExt; use nexus::{DomainEvent, Message, Version, version}; use nexus_example_projection_tokio::run_projection; -use nexus_store::testing::InMemoryStore; +use nexus_inmemory::InMemorySnapshotStore; +use nexus_inmemory::InMemoryStore; use nexus_store::{ - Decode, Encode, EveryNEvents, InMemorySnapshotStore, Projection, Projector, RawEventStore, - SnapshotStore, Store, Subscription, pending_envelope, + Decode, Encode, EveryNEvents, Projection, Projector, RawEventStore, SnapshotStore, Store, + Subscription, pending_envelope, }; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/examples/store-and-kernel/Cargo.toml b/examples/store-and-kernel/Cargo.toml index ae743aed..0dcbd73f 100644 --- a/examples/store-and-kernel/Cargo.toml +++ b/examples/store-and-kernel/Cargo.toml @@ -8,7 +8,8 @@ publish = false bytes.workspace = true futures.workspace = true nexus = { path = "../../crates/nexus", features = ["derive"] } -nexus-store = { path = "../../crates/nexus-store", features = ["testing"] } +nexus-inmemory = { path = "../../crates/nexus-inmemory" } +nexus-store = { path = "../../crates/nexus-store" } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/examples/store-and-kernel/src/main.rs b/examples/store-and-kernel/src/main.rs index 3f5a8e11..13d715f4 100644 --- a/examples/store-and-kernel/src/main.rs +++ b/examples/store-and-kernel/src/main.rs @@ -22,8 +22,8 @@ use futures::StreamExt; use nexus::*; +use nexus_inmemory::InMemoryStore; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::{Decode, Encode, StreamKey, pending_envelope}; use serde::{Deserialize, Serialize}; use std::fmt; diff --git a/examples/store-inmemory/Cargo.toml b/examples/store-inmemory/Cargo.toml index 712383f0..def5c9e8 100644 --- a/examples/store-inmemory/Cargo.toml +++ b/examples/store-inmemory/Cargo.toml @@ -8,7 +8,8 @@ publish = false bytes.workspace = true futures.workspace = true nexus = { path = "../../crates/nexus", features = ["derive"] } -nexus-store = { path = "../../crates/nexus-store", features = ["testing"] } +nexus-inmemory = { path = "../../crates/nexus-inmemory" } +nexus-store = { path = "../../crates/nexus-store" } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/examples/store-inmemory/src/main.rs b/examples/store-inmemory/src/main.rs index 3383a248..0b9ba556 100644 --- a/examples/store-inmemory/src/main.rs +++ b/examples/store-inmemory/src/main.rs @@ -25,9 +25,9 @@ use futures::{StreamExt, TryStreamExt}; use nexus::{Version, version}; +use nexus_inmemory::InMemoryStore; use nexus_store::Store; use nexus_store::store::RawEventStore; -use nexus_store::testing::InMemoryStore; use nexus_store::upcasting::EventMorsel; use nexus_store::{Decode, Encode, StreamKey, pending_envelope}; use serde::{Deserialize, Serialize}; From 65b7a1dab9cda8af98ef68292994584f182c1f90 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Thu, 9 Jul 2026 23:17:22 +0200 Subject: [PATCH 2/2] test(store): move import proptest regression seeds alongside relocated tests (#300) Co-Authored-By: Claude Fable 5 --- .../import.txt => tests/import_inline_tests.proptest-regressions} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/nexus-store/{proptest-regressions/import.txt => tests/import_inline_tests.proptest-regressions} (100%) diff --git a/crates/nexus-store/proptest-regressions/import.txt b/crates/nexus-store/tests/import_inline_tests.proptest-regressions similarity index 100% rename from crates/nexus-store/proptest-regressions/import.txt rename to crates/nexus-store/tests/import_inline_tests.proptest-regressions