Skip to main content

turnframe_store/conformance/
mod.rs

1//! An executable statement of the persistence contract.
2//!
3//! The traits in this crate carry their rules in prose, and prose does not
4//! fail a build. This module turns every rule an adopter could plausibly get
5//! wrong into a check that runs against *any* implementation through the
6//! public API only. If your store passes, the runtime's guarantees hold on it;
7//! if it fails, the failure names the rule and what it saw.
8//!
9//! It is always compiled — not behind a feature — because an adapter that
10//! cannot be verified from a dependency is an adapter nobody will verify.
11//!
12//! # Running it
13//!
14//! Give [`run_all`] something that builds an **empty** set of stores. It is
15//! called once per check, so checks never see each other's writes.
16//!
17//! ```rust
18//! use turnframe_store::conformance;
19//! use turnframe_store::stores::Stores;
20//!
21//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
22//! # tokio::runtime::Runtime::new()?.block_on(async {
23//! let report = conformance::run_all(&Stores::in_memory).await;
24//! assert!(report.passed(), "{report}");
25//! # Ok::<(), Box<dyn std::error::Error>>(())
26//! # })?;
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! While an adapter is still being written, run one rule at a time: every
32//! `check_*` function is public and takes a fresh [`Stores`].
33//!
34//! # It never panics
35//!
36//! Every check returns [`Result<(), ConformanceFailure>`](ConformanceFailure)
37//! and [`run_all`] collects them into a [`ConformanceReport`]. Nothing here
38//! unwraps, asserts or panics, so the suite is usable outside a test harness —
39//! in a deployment health check, or as a gate in a migration tool. Turning a
40//! failure into a test failure is the caller's choice: `assert!(report.passed())`,
41//! or `report.into_result()?`.
42//!
43//! # What is covered
44//!
45//! | Check | Rule |
46//! |---|---|
47//! | [`check_blocking_interaction_conflict`] | at most one open blocking card per case (I5) |
48//! | [`check_blocking_interaction_replace`] | replacing invalidates the occupant and mints a new id; a `Resolving` occupant is never replaced (spec §15.6) |
49//! | [`check_cross_tenant_isolation`] | another tenant's record is `NotFound`, indistinguishable from absent (spec §25.4) |
50//! | [`check_begin_resolution_cas`] | resolution starts only from the expected status |
51//! | [`check_finish_resolution_idempotent`] | settling twice the same way is accepted, differently is `Conflict` |
52//! | [`check_revision_invalidation_respects_independence`] | a revision change invalidates bound cards and spares independent ones (spec §15.5) |
53//! | [`check_interaction_expiry`] | expiry moves `Active` cards past their deadline, once |
54//! | [`check_answered_blocking_card_is_remembered`] | an answered card is remembered until the case moves |
55//! | [`check_journal_idempotency_replay`] | one `Fresh` per key, ever; every repeat replays the persisted outcome (I14) |
56//! | [`check_pending_for_turn_after_partial_write`] | a turn interrupted mid-flight is found by its unfinished entries (spec §23.1) |
57//! | [`check_awaiting_confirmation_is_never_resumed`] | a command journaled for a card is not unfinished work until the card is confirmed |
58//! | [`check_event_append_ordering_and_get_by_ids`] | append order is readback order; batches are atomic; reads are account-scoped |
59//! | [`check_event_stream_cursor_pages_exactly_once`] | the sequence cursor delivers every event of an account once, in order, while the journal grows underneath |
60//! | [`check_revision_read_truncates_inside_a_revision`] | the revision read caps its answer inside a revision and cannot resume — which is what the cursor read is for |
61//! | [`check_event_redaction_preserves_identity_and_order`] | erasing a payload leaves the event, its position and its neighbours exactly where they were — it is not a delete |
62//! | [`check_event_redaction_is_audited_and_idempotent`] | the erasure is recorded on the event with its authority, repeating it keeps the first record, and it cannot be aimed across tenants |
63//! | [`check_outbox_claim_exclusivity_and_reschedule`] | a claimed row is invisible to other workers until it is rescheduled or settled |
64//! | [`check_refused_settlement_writes_nothing`] | a refused settlement leaves the row byte-for-byte unchanged |
65//! | [`check_replay_put_get`] | one record per turn, upserted |
66//! | [`check_conversation_turn_persistence`] | an assistant turn reloads with identical, identically ordered blocks (spec §22.3) |
67//! | [`check_commit_bundle_applies_all`] | a bundle writes every item it carries |
68//! | [`check_commit_bundle_atomic_on_invalid_item`] | a bundle that fails writes nothing at all (spec §16.3) |
69//! | [`check_commit_bundle_restores_modified_records`] | a bundle that fails also puts back every record it *changed*, not only the ones it created |
70//! | [`check_commit_bundle_rejects_foreign_account_items`] | a bundle refuses an item of another tenant before writing |
71
72mod commit_bundle;
73mod conversations;
74mod events;
75mod fixtures;
76mod interactions;
77mod journal;
78mod outbox;
79mod replay;
80
81use std::fmt;
82
83use crate::stores::Stores;
84
85pub use self::commit_bundle::{
86    check_commit_bundle_applies_all, check_commit_bundle_atomic_on_invalid_item,
87    check_commit_bundle_rejects_foreign_account_items,
88    check_commit_bundle_restores_modified_records,
89};
90pub use self::conversations::check_conversation_turn_persistence;
91pub use self::events::{
92    check_event_append_ordering_and_get_by_ids, check_event_redaction_is_audited_and_idempotent,
93    check_event_redaction_preserves_identity_and_order,
94    check_event_stream_cursor_pages_exactly_once, check_revision_read_truncates_inside_a_revision,
95};
96pub use self::interactions::{
97    check_administrative_invalidation_ignores_the_revision,
98    check_answered_blocking_card_is_remembered, check_begin_resolution_cas,
99    check_blocking_interaction_conflict, check_blocking_interaction_replace,
100    check_cross_tenant_isolation, check_finish_resolution_idempotent, check_interaction_expiry,
101    check_revision_invalidation_respects_independence,
102};
103pub use self::journal::{
104    check_awaiting_confirmation_is_never_resumed, check_journal_idempotency_replay,
105    check_pending_for_turn_after_partial_write,
106};
107pub use self::outbox::{
108    check_outbox_claim_exclusivity_and_reschedule, check_refused_settlement_writes_nothing,
109};
110pub use self::replay::check_replay_put_get;
111
112/// One rule of the persistence contract that an implementation broke.
113///
114/// `detail` names what the check expected and what it observed. It contains
115/// fixture data only — the suite writes its own records — so it is safe to log
116/// in full.
117#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
118#[error("conformance check `{check}` failed: {detail}")]
119pub struct ConformanceFailure {
120    /// Name of the failing check, matching its function name.
121    pub check: &'static str,
122    /// What was expected and what happened.
123    pub detail: String,
124}
125
126impl ConformanceFailure {
127    /// Builds a failure.
128    #[must_use]
129    pub fn new(check: &'static str, detail: impl Into<String>) -> Self {
130        Self {
131            check,
132            detail: detail.into(),
133        }
134    }
135}
136
137/// What one check concluded.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct CheckOutcome {
140    /// Name of the check.
141    pub check: &'static str,
142    /// The failure, when it failed.
143    pub failure: Option<ConformanceFailure>,
144}
145
146impl CheckOutcome {
147    /// Returns `true` when the check passed.
148    #[must_use]
149    pub fn passed(&self) -> bool {
150        self.failure.is_none()
151    }
152}
153
154/// The result of a whole [`run_all`].
155///
156/// `Display` renders one line per check, so `assert!(report.passed(), "{report}")`
157/// prints a usable diagnosis.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct ConformanceReport {
160    /// Every check, in the order [`run_all`] ran them.
161    pub outcomes: Vec<CheckOutcome>,
162}
163
164impl ConformanceReport {
165    /// Returns `true` when every check passed.
166    #[must_use]
167    pub fn passed(&self) -> bool {
168        self.outcomes.iter().all(CheckOutcome::passed)
169    }
170
171    /// Every failure, in run order.
172    pub fn failures(&self) -> impl Iterator<Item = &ConformanceFailure> {
173        self.outcomes
174            .iter()
175            .filter_map(|outcome| outcome.failure.as_ref())
176    }
177
178    /// Turns the report into a `Result`, keeping the first failure.
179    ///
180    /// # Errors
181    /// * The first [`ConformanceFailure`] in run order.
182    pub fn into_result(self) -> Result<(), ConformanceFailure> {
183        match self.outcomes.into_iter().find_map(|o| o.failure) {
184            Some(failure) => Err(failure),
185            None => Ok(()),
186        }
187    }
188}
189
190impl fmt::Display for ConformanceReport {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        let failed = self.failures().count();
193        writeln!(
194            f,
195            "{} of {} conformance checks passed",
196            self.outcomes.len() - failed,
197            self.outcomes.len()
198        )?;
199        for outcome in &self.outcomes {
200            match &outcome.failure {
201                None => writeln!(f, "  pass  {}", outcome.check)?,
202                Some(failure) => writeln!(f, "  FAIL  {}: {}", outcome.check, failure.detail)?,
203            }
204        }
205        Ok(())
206    }
207}
208
209/// Builds an empty set of stores, once per check.
210///
211/// Any `Fn() -> Stores` implements it, so `&Stores::in_memory` or a closure
212/// that opens a fresh schema both work.
213pub trait StoreFactory: Send + Sync {
214    /// Returns a set of stores with no records in it.
215    fn build(&self) -> Stores;
216}
217
218impl<F> StoreFactory for F
219where
220    F: Fn() -> Stores + Send + Sync,
221{
222    fn build(&self) -> Stores {
223        self()
224    }
225}
226
227/// How many checks [`run_all`] runs.
228///
229/// Exported so a caller asserting full coverage cannot drift from the suite:
230/// a check added here changes this constant, while an assertion written
231/// against a literal would keep passing while covering less.
232pub const CHECK_COUNT: usize = 25;
233
234/// Runs every check against a fresh set of stores each and reports.
235///
236/// Never panics and never stops early: a broken implementation usually breaks
237/// several rules, and seeing all of them at once is faster to fix.
238pub async fn run_all(factory: &dyn StoreFactory) -> ConformanceReport {
239    let mut outcomes = Vec::new();
240    macro_rules! run {
241        ($($check:path),* $(,)?) => {
242            $(
243                let stores = factory.build();
244                outcomes.push(CheckOutcome {
245                    check: stringify!($check).rsplit("::").next().unwrap_or(stringify!($check)),
246                    failure: $check(&stores).await.err(),
247                });
248            )*
249        };
250    }
251    run!(
252        check_blocking_interaction_conflict,
253        check_blocking_interaction_replace,
254        check_cross_tenant_isolation,
255        check_begin_resolution_cas,
256        check_finish_resolution_idempotent,
257        check_revision_invalidation_respects_independence,
258        check_interaction_expiry,
259        check_answered_blocking_card_is_remembered,
260        check_administrative_invalidation_ignores_the_revision,
261        check_journal_idempotency_replay,
262        check_pending_for_turn_after_partial_write,
263        check_awaiting_confirmation_is_never_resumed,
264        check_event_append_ordering_and_get_by_ids,
265        check_event_stream_cursor_pages_exactly_once,
266        check_revision_read_truncates_inside_a_revision,
267        check_event_redaction_preserves_identity_and_order,
268        check_event_redaction_is_audited_and_idempotent,
269        check_outbox_claim_exclusivity_and_reschedule,
270        check_refused_settlement_writes_nothing,
271        check_replay_put_get,
272        check_conversation_turn_persistence,
273        check_commit_bundle_applies_all,
274        check_commit_bundle_atomic_on_invalid_item,
275        check_commit_bundle_restores_modified_records,
276        check_commit_bundle_rejects_foreign_account_items,
277    );
278    ConformanceReport { outcomes }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn report_renders_and_keeps_the_first_failure() {
287        let report = ConformanceReport {
288            outcomes: vec![
289                CheckOutcome {
290                    check: "check_a",
291                    failure: None,
292                },
293                CheckOutcome {
294                    check: "check_b",
295                    failure: Some(ConformanceFailure::new("check_b", "expected 1, got 2")),
296                },
297            ],
298        };
299        assert!(!report.passed());
300        assert_eq!(report.failures().count(), 1);
301        let rendered = report.to_string();
302        assert!(rendered.contains("1 of 2 conformance checks passed"));
303        assert!(rendered.contains("FAIL  check_b: expected 1, got 2"));
304        assert_eq!(
305            report.into_result().unwrap_err().check,
306            "check_b",
307            "into_result keeps the first failure"
308        );
309    }
310
311    #[test]
312    fn empty_report_passes() {
313        let report = ConformanceReport {
314            outcomes: Vec::new(),
315        };
316        assert!(report.passed());
317        assert!(report.into_result().is_ok());
318    }
319}