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}