Skip to main content

tephra_types/
query.rs

1//! Query model.
2//!
3//! Pure data, no I/O and no match logic (the match predicate lives in the engine, where
4//! it decodes an event). A [`Query`] describes which events a decision depends on, and the
5//! same query goes into the [`AppendCondition`] that guards the append. That reuse is what
6//! makes the consistency boundary dynamic: it covers exactly the events the decision read,
7//! nothing more.
8//!
9//! # Semantics
10//!
11//! A query is a set of [`QueryItem`]s OR'd together, plus a separate [`Query::All`]
12//! variant that matches everything. Within an item:
13//!
14//! - the event **type** must match *one* of the listed types (an empty type list
15//!   matches *any* type), and
16//! - the event **tags** must contain *all* of the item's tags.
17//!
18//! So the shape is **OR across items, AND within an item's tags**. An item with no
19//! tags constrains only on type; an empty [`Query::Items`] set matches nothing (an OR
20//! over zero alternatives).
21
22use crate::name::{EventType, Tags};
23use crate::position::Position;
24
25/// One alternative in a [`Query`]: a type constraint AND a tag constraint.
26///
27/// An event matches the item when its type is one of [`types`](Self::types) (or the
28/// list is empty, matching any type) and its tags are a superset of
29/// [`tags`](Self::tags).
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct QueryItem {
32    /// The types this item accepts. Empty means "any type". Not sorted: the list is
33    /// tiny (types are low cardinality) so membership is a linear scan.
34    pub types: Vec<EventType>,
35    /// Tags the event must contain *all* of. Sorted and duplicate-free by construction.
36    pub tags: Tags,
37}
38
39impl QueryItem {
40    /// An item constraining on both a set of types and a set of tags.
41    pub fn new(types: Vec<EventType>, tags: Tags) -> Self {
42        QueryItem { types, tags }
43    }
44
45    /// An item constraining only on type (matches any tags).
46    pub fn of_types(types: Vec<EventType>) -> Self {
47        QueryItem {
48            types,
49            tags: Tags::empty(),
50        }
51    }
52
53    /// An item constraining only on tags (matches any type).
54    pub fn with_tags(tags: Tags) -> Self {
55        QueryItem {
56            types: Vec::new(),
57            tags,
58        }
59    }
60}
61
62/// A query: a set of [`QueryItem`]s OR'd together, or the catch-all [`Query::All`].
63///
64/// [`All`](Query::All) is a distinct variant rather than "an item with no
65/// constraints" so the read and condition paths can recognise a full scan and bypass
66/// the index entirely. An empty [`Items`](Query::Items) set is the opposite: it matches
67/// nothing.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum Query {
70    /// Matches every event. Full scans and broad projection catch-up use this and skip
71    /// the index.
72    All,
73    /// Matches an event if *any* contained item matches (logical OR). Empty matches
74    /// nothing.
75    Items(Vec<QueryItem>),
76}
77
78impl Query {
79    /// The catch-all query, matching every event.
80    pub fn all() -> Self {
81        Query::All
82    }
83
84    /// A query over a set of items, OR'd together.
85    pub fn items(items: impl Into<Vec<QueryItem>>) -> Self {
86        Query::Items(items.into())
87    }
88
89    /// A query with a single item.
90    pub fn item(item: QueryItem) -> Self {
91        Query::Items(vec![item])
92    }
93}
94
95/// The guard on an `append` call: two independent checks, OR'd, so the append is rejected
96/// if either fires.
97///
98/// The **boundary check** ignores everything at or before [`after`](Self::after) and rejects
99/// the append if anything matching [`fail_if_events_match`](Self::fail_if_events_match) landed
100/// since. `after` is the highest position the client observed while building its decision
101/// model, which may be higher than the last matching event's position. Positions are 1-based,
102/// so [`after`](Self::after) `= Position::ZERO` (the default) means "consider the whole log":
103/// the spec's "omit `after`" case, i.e. fail if *any* event matches (the uniqueness-guard
104/// pattern).
105///
106/// The optional **existence check** ([`fail_if_exists`](Self::fail_if_exists)) rejects the
107/// append if any event *anywhere* matches its query, independent of `after` (an implicit
108/// `after = 0`). It is the idempotency/dedupe primitive: assert a key is globally absent even
109/// when the boundary legitimately advanced past events the decision read, which a single
110/// `after` cannot express. A conflict from this clause is reported distinctly from a boundary
111/// conflict (the engine's `ConflictClause`), so a client can treat "already applied"
112/// differently from "boundary moved, rebuild and retry".
113///
114/// This type only *holds* the condition. Evaluating it (position filtering plus the match
115/// predicate over the durable suffix) is the engine's job.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct AppendCondition {
118    /// Boundary check: reject the append if any event after [`after`](Self::after) matches
119    /// this query.
120    pub fail_if_events_match: Query,
121    /// Ignore everything at or before this position for the boundary check. `Position::ZERO`
122    /// means the whole log.
123    pub after: Position,
124    /// Optional existence check: reject the append if any event anywhere (implicit
125    /// `after = 0`) matches this query. `None` disables it.
126    pub fail_if_exists: Option<Query>,
127}
128
129impl AppendCondition {
130    /// A condition checking the whole log (`after = Position::ZERO`): fail if any event
131    /// matches.
132    pub fn new(fail_if_events_match: Query) -> Self {
133        AppendCondition {
134            fail_if_events_match,
135            after: Position::ZERO,
136            fail_if_exists: None,
137        }
138    }
139
140    /// A condition with no boundary check, only the existence clause: fail the append if any
141    /// event anywhere matches `query`. The pure idempotency/dedupe guard, without a decision
142    /// boundary. Equivalent to `AppendCondition::new(Query::items([])).fail_if_exists(query)`
143    /// (an empty boundary matches nothing), without the empty-boundary boilerplate.
144    pub fn exists_only(query: Query) -> Self {
145        AppendCondition {
146            fail_if_events_match: Query::items(Vec::new()),
147            after: Position::ZERO,
148            fail_if_exists: Some(query),
149        }
150    }
151
152    /// Sets the exclusive lower bound for the boundary check: only events strictly after
153    /// `after` are checked.
154    pub fn after(mut self, after: Position) -> Self {
155        self.after = after;
156        self
157    }
158
159    /// Adds the existence check: fail the append if any event anywhere (implicit `after = 0`)
160    /// matches `query`, independent of the boundary. The idempotency/dedupe guard.
161    pub fn fail_if_exists(mut self, query: Query) -> Self {
162        self.fail_if_exists = Some(query);
163        self
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn item_constructors() {
173        assert!(QueryItem::of_types(Vec::new()).tags.is_empty());
174        assert!(QueryItem::with_tags(Tags::empty()).types.is_empty());
175        assert_eq!(
176            QueryItem::default(),
177            QueryItem::new(Vec::new(), Tags::empty())
178        );
179    }
180
181    #[test]
182    fn query_constructors() {
183        assert_eq!(Query::all(), Query::All);
184        assert_eq!(Query::items(Vec::new()), Query::Items(Vec::new()));
185        assert_eq!(
186            Query::item(QueryItem::default()),
187            Query::Items(vec![QueryItem::default()])
188        );
189    }
190
191    #[test]
192    fn condition_defaults_to_position_zero() {
193        // Omitting `after` means "check the whole log", which under 1-based positions
194        // is `after: 0`.
195        let cond = AppendCondition::new(Query::all());
196        assert_eq!(cond.after, Position::ZERO);
197        assert_eq!(cond.fail_if_events_match, Query::All);
198        assert_eq!(cond.fail_if_exists, None);
199    }
200
201    #[test]
202    fn condition_after_sets_bound() {
203        let cond = AppendCondition::new(Query::all()).after(Position::new(42));
204        assert_eq!(cond.after, Position::new(42));
205    }
206
207    #[test]
208    fn condition_fail_if_exists_sets_the_clause() {
209        let dedupe = Query::item(QueryItem::with_tags(Tags::empty()));
210        let cond = AppendCondition::new(Query::all()).fail_if_exists(dedupe.clone());
211        assert_eq!(cond.fail_if_exists, Some(dedupe));
212        // Independent of the boundary bound.
213        assert_eq!(cond.after, Position::ZERO);
214    }
215
216    #[test]
217    fn exists_only_has_an_empty_boundary_and_the_existence_clause() {
218        let dedupe = Query::item(QueryItem::with_tags(Tags::empty()));
219        let cond = AppendCondition::exists_only(dedupe.clone());
220        // Empty boundary matches nothing, so only the existence clause can fire.
221        assert_eq!(cond.fail_if_events_match, Query::items(Vec::new()));
222        assert_eq!(cond.after, Position::ZERO);
223        assert_eq!(cond.fail_if_exists, Some(dedupe));
224    }
225}