Skip to main content

mnesis_store_testing/
lib.rs

1//! The executable conformance kit for `mnesis-store` adapters — and the guide
2//! to writing one (issue #281).
3//!
4//! Every store adapter (`mnesis-inmemory`, `mnesis-fjall`, `mnesis-postgres`,
5//! and any future one) implements the same seam:
6//! [`RawEventStore`](mnesis_store::store::RawEventStore) +
7//! [`WakeSource`](mnesis_store::wake::WakeSource), optionally `AtomicAppend`
8//! and [`SnapshotStore`](mnesis_store::state::SnapshotStore). That seam has
9//! a contract that goes well beyond "the trait compiles" — inclusive vs.
10//! exclusive read bounds, optimistic-conflict rejection, subscription
11//! catch-up→live ordering, concurrent-writer linearizability. This crate
12//! pins that contract as **runnable checks**, organized into four
13//! cross-cutting categories (CLAUDE.md rule 7) plus two opt-in capability
14//! modules:
15//!
16//! - [`sequence`] — multi-step protocol: append/read round-trips, optimistic
17//!   conflict + retry, `$all` ordering and resume, subscription
18//!   catch-up-then-live.
19//! - [`boundary`] — defensive inputs: version gaps, wrong first version,
20//!   metadata absent vs. present, max-length event types, prefix-colliding
21//!   stream ids.
22//! - [`linearizability`] — concurrent writers: single-winner on a contended
23//!   stream, all-land on distinct streams, wake-after-idle, the
24//!   `CaughtUp`-boundary race.
25//! - [`lifecycle`] — close → reopen (persistent adapters only): events,
26//!   the `$all` watermark, and conflict state all survive a reopen.
27//! - [`atomic`] (feature `atomic-append`) — `AtomicAppend`: multi-stream
28//!   commits are all-or-nothing.
29//! - [`snapshot`] (feature `snapshot`) — `SnapshotStore`: state and position
30//!   commit/hydrate atomically together, and a schema bump reads back
31//!   `Stale`, never decode garbage.
32//!
33//! The rest of this page is the **writing-a-store-adapter guide**. Sections
34//! 1–4 restate the seam's contract in one place (the trait docs in
35//! `mnesis-store` remain the normative source; follow the links); section 5
36//! shows how to prove an implementation with the kit; section 6 lists the
37//! pinned ambiguities that trip up new adapters. It assumes no knowledge of
38//! the shipped adapters — you never need to read `mnesis-fjall` or
39//! `mnesis-postgres` source.
40//!
41//! # What you implement
42//!
43//! One store type implements two mandatory traits.
44//!
45//! [`RawEventStore`](mnesis_store::store::RawEventStore) — bytes in, bytes
46//! out. The adapter never sees typed events or codecs; the repository facade
47//! encodes into [`PendingEnvelope`](mnesis_store::envelope::PendingEnvelope)s
48//! before calling you. You supply:
49//!
50//! - `type Error` — your own error type, bound
51//!   `core::error::Error + Send + Sync + 'static`. Keep it distinct from
52//!   `mnesis-store`'s facade error types; the facade wraps yours, and a shared
53//!   type would double-wrap.
54//! - `type Stream` — the per-stream read cursor: an owned, `'static`,
55//!   `Send` `futures::Stream` with
56//!   `Item = Result<PersistedEnvelope, Self::Error>` (the
57//!   [`EventStream`](mnesis_store::stream::EventStream) marker bound).
58//! - `type AllPosition` — your store-local `$all` resume position: any
59//!   `Copy + Ord + Send + Sync + Debug + 'static` type implementing
60//!   [`AllPosition`](mnesis_store::store::AllPosition). A scalar sequence
61//!   for an embedded store, a commit-ordered composite for a concurrent SQL
62//!   store. It is never carried on the envelope; it rides only on `$all`
63//!   items. `mnesis-store` ships no scalar impl, and the orphan rule blocks
64//!   `impl AllPosition for u64` in your crate — define a local newtype:
65//!   `struct MyPos(u64); impl AllPosition for MyPos {}` (plus the derives
66//!   the supertraits need).
67//! - `type AllStream` — the all-streams read cursor: an owned, `'static`,
68//!   `Send` `futures::Stream` with
69//!   `Item = Result<(Self::AllPosition, PersistedEnvelope), Self::Error>` —
70//!   every item tagged with its position.
71//! - Make both stream types `Unpin`. The trait imposes no such bound, but
72//!   the subscription path
73//!   ([`Subscription`](mnesis_store::subscription::Subscription)) requires it.
74//! - Three methods — [`append`](mnesis_store::store::RawEventStore::append),
75//!   [`read_stream`](mnesis_store::store::RawEventStore::read_stream),
76//!   [`read_all`](mnesis_store::store::RawEventStore::read_all) — whose
77//!   contracts are the next two sections.
78//!
79//! [`WakeSource`](mnesis_store::wake::WakeSource) — how live subscriptions
80//! learn that a commit landed. See "The wake contract" below.
81//!
82//! Optional capability traits, each with its own kit module:
83//!
84//! - `AtomicAppend` (at `mnesis_store::import::AtomicAppend`, behind
85//!   `mnesis-store`'s `import` feature) — commit several per-stream runs in
86//!   **one** transaction, all-or-nothing; the primitive bulk import needs.
87//!   Each write's `expected_version` is validated against the target's
88//!   **running** head (counting earlier writes to the same target inside the
89//!   batch); any mismatch aborts the whole transaction with
90//!   `AtomicAppendError::Conflict { index, actual }`, and on any failure
91//!   **no** write is applied.
92//! - [`SnapshotStore<Vec<u8>, P>`](mnesis_store::state::SnapshotStore) —
93//!   atomic persistence of derived state plus the position it was folded to
94//!   (`hydrate` / `commit`). Byte-level: `S = Vec<u8>`; typed state is a
95//!   codec bridge upstream, not your concern. Two useful instantiations:
96//!   `P = Version` (aggregate snapshots) and `P =` your `AllPosition`
97//!   (projection checkpoints). `hydrate` returns the three-state
98//!   [`Hydrated`](mnesis_store::state::Hydrated) — `Absent` (never saved),
99//!   `Stale` (saved under a different schema version; the caller rebuilds),
100//!   `Found` (position + state). State and position commit **together**: the
101//!   trait has no "save state alone", and your implementation must persist
102//!   the pair atomically so a half-write is unrepresentable.
103//!
104//! Consumers never call you directly — they go through the `Store<S>`
105//! handle, repositories, and subscriptions, all generic over the seam.
106//! Implement the traits and everything above them works.
107//!
108//! ## Storing an event
109//!
110//! `append` hands you `&[PendingEnvelope]`; reads must hand back
111//! [`PersistedEnvelope`](mnesis_store::envelope::PersistedEnvelope)s. The
112//! supported recipe is the canonical wire frame: persist, per event, the
113//! `Version` (from `PendingEnvelope::version()`) plus the output of
114//! [`encode_frame`](mnesis_store::wire::encode_frame):
115//!
116//! ```ignore
117//! let frame = mnesis_store::wire::encode_frame(
118//!     env.schema_version_value(),
119//!     &env.event_type_value(),
120//!     &env.payload_value(),
121//!     env.metadata_value().as_ref(),
122//! )?; // EncodedFrame { value: Bytes, offsets: FrameOffsets }
123//! ```
124//!
125//! Store `frame.value` (one contiguous `Bytes` buffer), `frame.offsets`,
126//! and the `SchemaVersion`; on read, rebuild with
127//! [`PersistedEnvelope::try_new`](mnesis_store::envelope::PersistedEnvelope::try_new)
128//! `(version, value, schema_version, offsets.event_type, offsets.payload,
129//! offsets.metadata)`. The frame lands the payload on a 16-byte boundary
130//! inside the buffer — an invariant zero-copy codecs (rkyv, POD) rely on. A
131//! custom storage layout is allowed, but then payload alignment and field
132//! re-validation are on you.
133//!
134//! # The append contract
135//!
136//! [`append(id, expected_version, envelopes)`](mnesis_store::store::RawEventStore::append)
137//! is optimistic concurrency:
138//!
139//! - `expected_version` is the stream head the caller last saw: `None` = a
140//!   fresh stream with no events, `Some(v)` = the head is exactly `v`.
141//!   Compare it against the stream's **actual** current head; on mismatch
142//!   return [`AppendError::Conflict`](mnesis_store::error::AppendError)
143//!   carrying the stream id, the caller's expectation, and the actual head —
144//!   the caller reloads from `actual` and retries. The diagnostic id field
145//!   is `mnesis::ErrorId`, built truncation-aware from the key's `Display`:
146//!   `stream_id: ErrorId::from_display(id)`.
147//! - The head check and the event insertion **must** be one atomic step (a
148//!   transaction, CAS, or a lock). A check-then-insert with a window between
149//!   lets a concurrent writer slip in and corrupt the stream; the kit's
150//!   linearizability checks race real writers at exactly this seam.
151//! - Envelope versions must run strictly sequentially from
152//!   `expected_version + 1` (from `1` when `None`). A gap, duplicate, or
153//!   out-of-order batch is rejected in the `Conflict` domain — and
154//!   **nothing** lands: a rejected append leaves the store byte-identical,
155//!   per-stream and `$all` alike. In that `Conflict`, `expected` is the
156//!   caller's stated expectation and `actual` is the store's current head —
157//!   the fields describe the head disagreement, never the malformed batch.
158//! - Stamp every accepted event with the next `AllPosition`: strictly
159//!   monotonic across **all** streams in commit order, **not** required to
160//!   be gapless — an aborted append may burn positions, and readers
161//!   tolerate the gaps.
162//! - An empty `envelopes` slice: run the head check first (a stale
163//!   `expected_version` is still a `Conflict`), then return `Ok` — nothing
164//!   written, nobody woken.
165//! - After the commit is durable — never before — fire your wake path (see
166//!   "The wake contract").
167//!
168//! # The read contract
169//!
170//! Two read methods, deliberately asymmetric.
171//!
172//! [`read_stream(id, from)`](mnesis_store::store::RawEventStore::read_stream)
173//! — a bounded scan of one stream:
174//!
175//! - `from` is **inclusive**: yield every event with `version >= from`, in
176//!   ascending `Version` order, then terminate with `None`.
177//! - An absent stream is an **empty** stream, never an error.
178//! - After `None` the stream stays `None` (fused) — the kit polls again to
179//!   prove it.
180//! - Internal batching/pagination is allowed and must be invisible; bounding
181//!   resident memory is your concern (fjall, for instance, holds one lazy
182//!   LSM cursor rather than fixed-size batches).
183//!
184//! [`read_all(from: Option<AllPosition>)`](mnesis_store::store::RawEventStore::read_all)
185//! — a bounded scan across all streams:
186//!
187//! - `from` is **exclusive**: `None` = from the very beginning, `Some(p)` =
188//!   strictly after `p`. Yield in ascending position order, each item tagged
189//!   `(position, envelope)`, then terminate with `None` when caught up.
190//! - Resume is `Ord`-based: the subscription loop reopens with the last
191//!   position it delivered, and there is deliberately no successor function.
192//!   Your scan must read "strictly greater than `from`" — tolerating gaps by
193//!   scanning a range, never by stepping `+1`.
194//!
195//! The asymmetry (inclusive `Version` vs. exclusive `AllPosition`) is
196//! intentional: a single stream's versions are a gapless successor sequence,
197//! so the resume seam computes `v + 1` itself and asks inclusively — while a
198//! composite `$all` position (e.g. postgres's transaction-ordered pair) has
199//! no natural `+1`, so resume must be "strictly after what I saw". Both
200//! reads serve the same strict-after resume; the difference is who computes
201//! the successor.
202//!
203//! What a scan opened *before* a concurrent commit observes is
204//! adapter-unspecified — see "Contract notes".
205//!
206//! # The wake contract
207//!
208//! A live subscription is a catch-up-then-park loop; the loop itself ships
209//! generically in `mnesis-store` and works for any adapter. Your half is
210//! [`WakeSource`](mnesis_store::wake::WakeSource): two methods and one
211//! call-site discipline.
212//!
213//! - [`register(stream: Option<&[u8]>)`](mnesis_store::wake::WakeSource::register)
214//!   — called once, synchronously, when a subscription opens
215//!   (`None` registers for `$all`). Return a
216//!   [`WakeRegistration`](mnesis_store::wake::WakeRegistration) that keeps
217//!   wake-routing alive until dropped.
218//! - [`arm`](mnesis_store::wake::WakeRegistration::arm) — returns an owned
219//!   `'static` future. Contract: the future captures a "seen point" at the
220//!   moment `arm` is called and resolves once a wake is delivered **after**
221//!   that point — a wake landing between `arm` and the `.await` must NOT be
222//!   lost. The generic loop arms *before* its confirming re-scan whenever it
223//!   thinks it is caught up; that ordering plus your arm-time capture is the
224//!   entire lost-wakeup defense.
225//! - [`wake(stream)`](mnesis_store::wake::WakeSource::wake) — call after
226//!   **every** durable commit to `stream`, never before (a woken subscriber
227//!   immediately re-reads and must see the data). A per-stream commit is
228//!   also an `$all` event: `$all` observers must be woken too.
229//! - Spurious wakes are permitted — each costs one empty re-scan. Lost wakes
230//!   are not — a lost wake is a subscription hung forever, and the kit's
231//!   `check_wake_after_idle` and `check_caught_up_boundary_race` exist to
232//!   catch exactly that.
233//!
234//! In-process adapters should not build this machinery: embed
235//! `mnesis_wake::StreamNotifiers` and delegate — the exact shape
236//! `mnesis-inmemory` and `mnesis-fjall` ship:
237//!
238//! ```ignore
239//! use std::sync::Arc;
240//!
241//! use mnesis_store::wake::WakeSource;
242//! use mnesis_wake::{NotifyError, StreamNotifiers, WakeReg};
243//!
244//! struct MyStore {
245//!     // ... your storage ...
246//!     notifiers: Arc<StreamNotifiers>, // StreamNotifiers::new() -> Arc<StreamNotifiers>
247//! }
248//!
249//! impl WakeSource for MyStore {
250//!     type Registration = WakeReg;
251//!     type Error = NotifyError;
252//!
253//!     fn register(&self, stream: Option<&[u8]>) -> Result<WakeReg, NotifyError> {
254//!         self.notifiers.register(stream)
255//!     }
256//!
257//!     fn wake(&self, stream: &[u8]) {
258//!         self.notifiers.wake(stream); // per-stream subscribers + the `$all` generation
259//!     }
260//! }
261//! ```
262//!
263//! …and at the end of a successful non-empty `append`, after the commit is
264//! durable: `self.notifiers.wake(id.as_ref());` (one call — it wakes the
265//! stream's subscribers and bumps the store-wide `$all` generation).
266//! A distributed adapter implements the same two traits over its own signal
267//! (postgres: `LISTEN`/`NOTIFY`).
268//!
269//! # Running the kit
270//!
271//! An adapter proves conformance by invoking the [`conformance!`] macro (and
272//! the capability macros it needs) once, from one test file. Each generates
273//! one named `#[tokio::test]` per check, so nextest reports every contract
274//! rule as its own test — a failure names the exact rule that broke, not
275//! "some test in the suite." Dependencies you'll need: `tokio` with
276//! `macros` + `rt-multi-thread` (plus `sync`/`time` if your adapter uses
277//! tokio primitives), `thiserror` for your error enum (workspace rule), and
278//! `mnesis-wake` for the in-process `WakeSource`.
279//!
280//! ## The factory contract
281//!
282//! Every macro takes a factory: `Fn() -> Fut` where
283//! `Fut: Future<Output = (S, C)> + Send`, `S: RawEventStore + WakeSource`.
284//! `C` is an adapter-chosen **guard** kept alive for the check's duration —
285//! a `TempDir` for fjall, `()` for an adapter that owns its storage outright
286//! (in-memory, a connection pool). The factory is called once per generated
287//! test and must produce a **fresh** store each call; checks never share
288//! state across tests.
289//!
290//! ```ignore
291//! mnesis_store_testing::conformance! {
292//!     factory: || async { (InMemoryStore::new(), ()) },
293//! }
294//! ```
295//!
296//! ## Capability and lifecycle macros
297//!
298//! - [`conformance_atomic_append!`] — `AtomicAppend` checks; requires the
299//!   `atomic-append` feature and an `S: AtomicAppend` factory.
300//! - [`conformance_snapshot!`] — `SnapshotStore` checks; requires the
301//!   `snapshot` feature, an `S: SnapshotStore<_, P>` factory, and two pairs
302//!   of ascending sample `P` positions: `positions` (ordinary values) and
303//!   `extremes` (the representable edges, proving the position codec has no
304//!   off-by-one at either end).
305//! - [`conformance_lifecycle!`] — close/reopen checks against the SAME
306//!   backing storage; skipped entirely by in-memory adapters (nothing to
307//!   reopen), run by every persistent adapter (fjall, postgres). Takes two
308//!   closures: `open` (the usual factory shape) and `reopen`
309//!   (`Fn(S, C) -> Fut<Output = (S, C)>`), which consumes the prior pair so
310//!   it can drop the store before reopening the same storage.
311//!
312//! ## `skip_unless:` for environment-gated adapters
313//!
314//! Every macro accepts an optional `skip_unless: <fn() -> bool>` that guards
315//! each generated test: when it returns `false` the test returns
316//! immediately (a vacuous pass, not a failure). `mnesis-postgres` uses this
317//! to skip the whole matrix when `DATABASE_URL` is unset locally, while
318//! still running for real under the nixosTest CI attribute that supplies a
319//! live database.
320//!
321//! # Contract notes
322//!
323//! Ambiguities pinned during this work, load-bearing for anyone writing a
324//! new adapter:
325//!
326//! - **Read visibility under concurrent append is adapter-unspecified.**
327//!   Whether a reader started before a concurrent commit observes it is not
328//!   part of the contract — `FjallStore` pins one snapshot at scan-open
329//!   (repeatable-read), `InMemoryStore` re-reads live state on each refill.
330//!   Both are conformant; the kit asserts eventual convergence (every
331//!   committed event is observed once the reader catches up), never
332//!   mid-flight visibility.
333//! - **`GlobalSeq` / `$all` positions are strictly monotonic but never
334//!   gapless.** An aborted append may burn a position with no event landing
335//!   there. Checks assert strict ordering, never contiguity.
336//! - **Empty stream ids are a permitted adapter limitation.** fjall/LSM
337//!   rejects an empty key, so the kit only ever constructs non-empty stream
338//!   ids — an adapter is not required to support the empty id.
339//! - **`Some(empty)` metadata is unrepresentable by construction.** The
340//!   envelope's metadata value type rejects a zero-length `Some` at
341//!   construction (`ValueError::MetadataEmpty`) — the wire format reserves
342//!   `u32::MAX` as the absent-metadata sentinel, so "empty but present"
343//!   would collide with "absent". The boundary check is
344//!   `check_metadata_absent_vs_present_distinct`, not …`_vs_empty_`.
345//! - **Public error enums are `#[non_exhaustive]`.**
346//!   [`AppendError`](mnesis_store::error::AppendError),
347//!   `AtomicAppendError`, and their siblings may grow variants without a
348//!   major bump — match the variant you handle (`Conflict`) plus a wildcard
349//!   arm, never exhaustively.
350
351#![allow(
352    clippy::unwrap_used,
353    reason = "test harness — assertions naturally use unwrap"
354)]
355#![allow(
356    clippy::expect_used,
357    reason = "test harness — assertions naturally use expect"
358)]
359#![allow(clippy::panic, reason = "test harness — failures signal via panic")]
360#![allow(
361    clippy::missing_panics_doc,
362    reason = "test harness — every check panics on failure"
363)]
364pub mod boundary;
365pub mod lifecycle;
366pub mod linearizability;
367pub mod row;
368pub mod sequence;
369
370#[cfg(feature = "atomic-append")]
371pub mod atomic;
372#[cfg(feature = "snapshot")]
373pub mod snapshot;
374
375pub use row::{ConformanceRow, SubId};
376
377// ═══════════════════════════════════════════════════════════════════════════
378// `conformance!` macro entry points (#281)
379// ═══════════════════════════════════════════════════════════════════════════
380
381/// One generated conformance test: skip-guard, factory, check call.
382#[doc(hidden)]
383#[macro_export]
384macro_rules! __conformance_case {
385    ($module:ident, $check:ident, $factory:expr, $skip:expr) => {
386        #[tokio::test]
387        async fn $check() {
388            if !($skip)() {
389                return;
390            }
391            $crate::$module::$check(&$factory).await;
392        }
393    };
394    (multi_thread: $module:ident, $check:ident, $factory:expr, $skip:expr) => {
395        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
396        async fn $check() {
397            if !($skip)() {
398                return;
399            }
400            $crate::$module::$check(&$factory).await;
401        }
402    };
403}
404
405/// Run the full core conformance matrix (sequence + boundary +
406/// linearizability) against a store factory.
407///
408/// The factory returns `(store, guard)`: the guard keeps any backing resource
409/// (a temp dir, a pool) alive for the check's duration — use `()` when the
410/// store owns everything.
411///
412/// ```ignore
413/// mnesis_store_testing::conformance! {
414///     factory: || async { (InMemoryStore::new(), ()) },
415/// }
416/// ```
417///
418/// Requires `tokio` (with `macros`, `rt-multi-thread`) as a dev-dependency of
419/// the invoking crate.
420#[macro_export]
421macro_rules! conformance {
422    (factory: $factory:expr $(,)?) => {
423        $crate::conformance! { factory: $factory, skip_unless: || true }
424    };
425    (factory: $factory:expr, skip_unless: $skip:expr $(,)?) => {
426        mod conformance_sequence {
427            #[allow(clippy::wildcard_imports, reason = "re-import the invocation scope")]
428            use super::*;
429
430            $crate::__conformance_case!(sequence, check_empty_read_yields_none, $factory, $skip);
431            $crate::__conformance_case!(sequence, check_append_then_read_round_trips, $factory, $skip);
432            $crate::__conformance_case!(sequence, check_versions_strictly_monotonic_and_fused, $factory, $skip);
433            $crate::__conformance_case!(sequence, check_large_stream_completes, $factory, $skip);
434            $crate::__conformance_case!(sequence, check_read_stream_from_is_inclusive, $factory, $skip);
435            $crate::__conformance_case!(sequence, check_append_conflict_is_surfaced, $factory, $skip);
436            $crate::__conformance_case!(sequence, check_append_retry_after_conflict_succeeds, $factory, $skip);
437            $crate::__conformance_case!(sequence, check_all_empty_store_yields_none, $factory, $skip);
438            $crate::__conformance_case!(sequence, check_all_global_order_across_streams, $factory, $skip);
439            $crate::__conformance_case!(sequence, check_all_from_is_exclusive, $factory, $skip);
440            $crate::__conformance_case!(sequence, check_all_multi_resume_cycles, $factory, $skip);
441            $crate::__conformance_case!(sequence, check_all_boundary_then_new_append, $factory, $skip);
442            $crate::__conformance_case!(sequence, check_read_stream_inclusive_read_all_exclusive_coexist, $factory, $skip);
443            $crate::__conformance_case!(sequence, check_subscription_backlog_then_caught_up_then_live, $factory, $skip);
444            $crate::__conformance_case!(sequence, check_subscription_resume_strict_after, $factory, $skip);
445            $crate::__conformance_case!(sequence, check_subscription_all_backlog_then_caught_up_then_live, $factory, $skip);
446            $crate::__conformance_case!(sequence, check_subscription_large_backlog_crosses_chunk_seam, $factory, $skip);
447            $crate::__conformance_case!(sequence, check_subscription_absent_stream_waits_then_delivers, $factory, $skip);
448            $crate::__conformance_case!(sequence, check_subscription_beyond_head_filters_below_bound, $factory, $skip);
449            $crate::__conformance_case!(sequence, check_two_subscribers_same_stream_both_receive, $factory, $skip);
450        }
451
452        mod conformance_boundary {
453            #[allow(clippy::wildcard_imports, reason = "re-import the invocation scope")]
454            use super::*;
455
456            $crate::__conformance_case!(boundary, check_conflict_leaves_store_unchanged, $factory, $skip);
457            $crate::__conformance_case!(boundary, check_version_gap_batch_rejected, $factory, $skip);
458            $crate::__conformance_case!(boundary, check_wrong_first_version_rejected, $factory, $skip);
459            $crate::__conformance_case!(boundary, check_metadata_absent_vs_present_distinct, $factory, $skip);
460            $crate::__conformance_case!(boundary, check_max_length_event_type_round_trips, $factory, $skip);
461            $crate::__conformance_case!(boundary, check_prefix_stream_ids_isolated, $factory, $skip);
462            $crate::__conformance_case!(boundary, check_large_payload_round_trips, $factory, $skip);
463        }
464
465        mod conformance_linearizability {
466            #[allow(clippy::wildcard_imports, reason = "re-import the invocation scope")]
467            use super::*;
468
469            $crate::__conformance_case!(multi_thread: linearizability, check_concurrent_same_stream_single_winner, $factory, $skip);
470            $crate::__conformance_case!(multi_thread: linearizability, check_concurrent_distinct_streams_all_land, $factory, $skip);
471            $crate::__conformance_case!(multi_thread: linearizability, check_wake_after_idle, $factory, $skip);
472            $crate::__conformance_case!(multi_thread: linearizability, check_caught_up_boundary_race, $factory, $skip);
473        }
474    };
475}
476
477/// Run the `AtomicAppend` capability conformance (feature `atomic-append`).
478///
479/// Same factory shape as [`conformance!`]: `Fn() -> Fut<Output = (S, C)>`
480/// with `S: AtomicAppend`.
481///
482/// ```ignore
483/// mnesis_store_testing::conformance_atomic_append! {
484///     factory: || async { (InMemoryStore::new(), ()) },
485/// }
486/// ```
487#[cfg(feature = "atomic-append")]
488#[macro_export]
489macro_rules! conformance_atomic_append {
490    (factory: $factory:expr $(,)?) => {
491        $crate::conformance_atomic_append! { factory: $factory, skip_unless: || true }
492    };
493    (factory: $factory:expr, skip_unless: $skip:expr $(,)?) => {
494        mod conformance_atomic {
495            #[allow(clippy::wildcard_imports, reason = "re-import the invocation scope")]
496            use super::*;
497
498            $crate::__conformance_case!(
499                atomic,
500                check_atomic_multi_stream_commits_all,
501                $factory,
502                $skip
503            );
504            $crate::__conformance_case!(atomic, check_atomic_conflict_aborts_all, $factory, $skip);
505            $crate::__conformance_case!(atomic, check_atomic_empty_batch_is_noop, $factory, $skip);
506        }
507    };
508}
509
510/// Run the `SnapshotStore` capability conformance (feature `snapshot`).
511///
512/// Same factory shape as [`conformance!`], with `S: SnapshotStore<_, P>`.
513/// `positions` are two ascending sample positions of the store's `P` — used
514/// to drive the hydrate/commit checks without hardcoding a position type.
515///
516/// `extremes` are two ascending positions at the `P` type's representable
517/// edges (smallest and at/near the ceiling) — used to prove the position
518/// codec has no off-by-one at either edge.
519///
520/// ```ignore
521/// mnesis_store_testing::conformance_snapshot! {
522///     factory: || async { (InMemorySnapshotStore::<Vec<u8>, Version>::new(), ()) },
523///     positions: (Version::new(5).unwrap(), Version::new(9).unwrap()),
524///     extremes: (Version::new(1).unwrap(), Version::new(u64::MAX).unwrap()),
525/// }
526/// ```
527#[cfg(feature = "snapshot")]
528#[macro_export]
529macro_rules! conformance_snapshot {
530    (factory: $factory:expr, positions: ($p1:expr, $p2:expr), extremes: ($pmin:expr, $pmax:expr) $(,)?) => {
531        $crate::conformance_snapshot! { factory: $factory, positions: ($p1, $p2), extremes: ($pmin, $pmax), skip_unless: || true }
532    };
533    (factory: $factory:expr, positions: ($p1:expr, $p2:expr), extremes: ($pmin:expr, $pmax:expr), skip_unless: $skip:expr $(,)?) => {
534        mod conformance_snapshot {
535            #[allow(clippy::wildcard_imports, reason = "re-import the invocation scope")]
536            use super::*;
537
538            #[tokio::test]
539            async fn check_snapshot_absent_then_commit_then_found() {
540                if !($skip)() {
541                    return;
542                }
543                $crate::snapshot::check_snapshot_absent_then_commit_then_found(&$factory, $p1, $p2)
544                    .await;
545            }
546            #[tokio::test]
547            async fn check_snapshot_stale_on_schema_change() {
548                if !($skip)() {
549                    return;
550                }
551                $crate::snapshot::check_snapshot_stale_on_schema_change(&$factory, $p1, $p2).await;
552            }
553            #[tokio::test]
554            async fn check_snapshot_overwrite_latest_wins() {
555                if !($skip)() {
556                    return;
557                }
558                $crate::snapshot::check_snapshot_overwrite_latest_wins(&$factory, $p1, $p2).await;
559            }
560            #[tokio::test]
561            async fn check_snapshot_empty_state_round_trips() {
562                if !($skip)() {
563                    return;
564                }
565                $crate::snapshot::check_snapshot_empty_state_round_trips(&$factory, $p1, $p2).await;
566            }
567            #[tokio::test]
568            async fn check_snapshot_extreme_positions_round_trip() {
569                if !($skip)() {
570                    return;
571                }
572                $crate::snapshot::check_snapshot_extreme_positions_round_trip(
573                    &$factory, $pmin, $pmax,
574                )
575                .await;
576            }
577        }
578    };
579}
580
581/// Run the lifecycle conformance (persistent adapters only): `open` yields a
582/// fresh `(store, ctx)`; `reopen` consumes both and reopens the SAME storage.
583///
584/// `open` has the same factory shape as [`conformance!`]; `reopen` is
585/// `Fn(S, C) -> Fut<Output = (S, C)>`, taking ownership of the prior
586/// `(store, guard)` pair so it can drop the store before reopening the same
587/// backing storage.
588///
589/// ```ignore
590/// mnesis_store_testing::conformance_lifecycle! {
591///     open: open_fresh,
592///     reopen: |store: FjallStore, dir: TempDir| async move {
593///         drop(store);
594///         let reopened = FjallStore::builder(dir.path().join("db"))
595///             .open()
596///             .expect("reopen fjall store");
597///         (reopened, dir)
598///     },
599/// }
600/// ```
601#[macro_export]
602macro_rules! conformance_lifecycle {
603    (open: $open:expr, reopen: $reopen:expr $(,)?) => {
604        $crate::conformance_lifecycle! { open: $open, reopen: $reopen, skip_unless: || true }
605    };
606    (open: $open:expr, reopen: $reopen:expr, skip_unless: $skip:expr $(,)?) => {
607        mod conformance_lifecycle {
608            #[allow(clippy::wildcard_imports, reason = "re-import the invocation scope")]
609            use super::*;
610
611            #[tokio::test]
612            async fn check_reopen_preserves_events() {
613                if !($skip)() {
614                    return;
615                }
616                $crate::lifecycle::check_reopen_preserves_events(&$open, &$reopen).await;
617            }
618            #[tokio::test]
619            async fn check_reopen_preserves_position_watermark() {
620                if !($skip)() {
621                    return;
622                }
623                $crate::lifecycle::check_reopen_preserves_position_watermark(&$open, &$reopen)
624                    .await;
625            }
626            #[tokio::test]
627            async fn check_reopen_conflict_state_intact() {
628                if !($skip)() {
629                    return;
630                }
631                $crate::lifecycle::check_reopen_conflict_state_intact(&$open, &$reopen).await;
632            }
633            #[tokio::test]
634            async fn check_reopen_subscription_catches_up() {
635                if !($skip)() {
636                    return;
637                }
638                $crate::lifecycle::check_reopen_subscription_catches_up(&$open, &$reopen).await;
639            }
640        }
641    };
642}