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}