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