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}