Skip to main content

eventsdb_core/
log.rs

1//! The database-level SPI: one level above a stream.
2//!
3//! [`crate::store::EventStore`] is scoped to a single stream and stays that
4//! way. Everything that is a question about the database as a whole lives
5//! here: reading across streams, following the log as it grows, remembering
6//! how far a consumer got — and moving the whole thing somewhere else.
7//!
8//! That last one is on the trait rather than on a backend on purpose. A log
9//! that cannot be moved is a log its owner cannot leave, so being movable is a
10//! claim this crate makes, not a convenience one implementation happens to
11//! offer. A caller generic over `EventLog` can therefore write a migration.
12//!
13//! # Three read axes
14//!
15//! A cross-stream read or a subscription narrows the log along three axes,
16//! and only three. Each is a column or a key the caller wrote, matched on the
17//! **stored** shape before the upcaster chain runs:
18//!
19//! ```text
20//!              position ───────────────────────────────────────▶
21//!
22//!   streams  │ orders/17 │ orders/17 │ orders/18 │ orders/17 │ orders/19 │
23//!   kinds    │ placed    │ paid      │ placed    │ closed    │ placed    │
24//!   meta     │ tenant=a  │ tenant=a  │ tenant=b  │ tenant=a  │ tenant=a  │
25//!            │           │           │           │ closed=1  │           │
26//!
27//!   Filter { streams: [orders/17, orders/18] }     ─▶ 1st 2nd 3rd 4th
28//!   Filter { kinds: [placed] }                     ─▶ 1st     3rd     5th
29//!   Filter { meta: [(tenant, a)] }                 ─▶ 1st 2nd     4th 5th
30//!   Filter { kinds: [closed], meta: [(tenant, a)] } ─▶             4th
31//! ```
32//!
33//! `streams` and `kinds` are sets: a member matches. `meta` is a list of
34//! `(key, value)` pairs, every pair must match, and a key an event does not
35//! carry matches nothing — absence is not a value. Axes combine by AND.
36//!
37//! What the `meta` axis is: equality on a scalar the caller wrote, the same
38//! operation `kind IN (...)` is. What it is not: a query language. There is
39//! no range, no prefix, no `OR` across keys, and no reading inside `data`.
40//! A caller with that question builds a projection, which is what a
41//! projection is for; the axis exists so that the *properties a projection
42//! keys on* are cheap to read from the log directly, not so that the log
43//! becomes the projection. Why the properties live under `meta` at all,
44//! lifecycle included, is [`crate::event`]'s to say.
45//!
46//! Matching happens on the stored shape for the reason `kinds` states: an
47//! upcaster runs on the way *out*, so a key it would add is not in the row
48//! to be matched. A caller that renames a `meta` key has two names to filter
49//! by until the old rows are gone — which is a fact about the log, and the
50//! filter reports it rather than hiding it.
51//!
52//! # A read is a page; a stream is the page loop
53//!
54//! [`EventLog::read_all`] returns a `Vec` bounded by `limit`, and is
55//! exclusive on `from` so the position of the last event handled is the
56//! next call's `from`. That is the whole of the read contract: a page, and a
57//! cursor the caller owns. It is deliberately not a cursor the *log* owns —
58//! an open cursor on an embedded database is an open read transaction, and
59//! what that costs is the backend's to state, not this trait's to hide.
60//!
61//! Anything that reads more than a page is that call in a loop, and the
62//! loop is the same whether it ends when the range runs dry or waits there
63//! for more: [`EventLog::subscribe`] is the waiting form on this trait, and a
64//! backend may offer the ending form beside it. Neither holds anything
65//! between pages that `read_all` did not hold during one.
66
67use async_trait::async_trait;
68use futures_core::stream::BoxStream;
69use serde_json::Value;
70
71use crate::error::{Error, Result};
72use crate::event::FIELD_META;
73use crate::position::{Position, Recorded};
74use crate::store::EventStore;
75use crate::transfer::{ExportedEvent, ImportReport};
76
77/// Which events a cross-stream read or a subscription wants.
78///
79/// Every axis is matched on the **stored** shape — the stored kind, the
80/// stored stream name, the stored `meta` — before the upcaster chain runs,
81/// the same rule [`EventStore::read_kinds`] follows, for the same reason.
82/// The module doc has the three axes side by side.
83///
84/// `#[non_exhaustive]`, so an axis can be added without breaking a caller.
85/// Start from [`Filter::all`] or [`Filter::kinds`] and narrow with the
86/// methods, or set the public fields on a default; only the struct literal
87/// is reserved.
88#[derive(Debug, Clone, Default)]
89#[non_exhaustive]
90pub struct Filter {
91    /// Kinds to include. `None` includes every kind; an empty vector selects
92    /// nothing, which is the honest reading of "include these" given none.
93    pub kinds: Option<Vec<String>>,
94    /// Streams to include. `None` reads every stream; an empty vector selects
95    /// nothing, the same reading `kinds` gets.
96    ///
97    /// **A set, not one name.** Restricting to a single stream is the common
98    /// case and [`Filter::stream`] still spells it, but the question a caller
99    /// actually has is often about a *group* — the streams of one session, of
100    /// one tenant, of one run. Given only a single-stream filter that has to
101    /// be answered with one read per stream and a merge in the caller, which
102    /// loses the position order the log exists to provide.
103    pub streams: Option<Vec<String>>,
104    /// `meta` keys and the value each must hold. `None` and an empty vector
105    /// both place no condition — there is no "these keys" to be given none
106    /// of, so the two readings coincide here where they diverge above.
107    ///
108    /// **Pairs, not a set.** Every pair must hold, so two pairs on one key
109    /// with different values select nothing, which is what "both" means. A
110    /// value is a string, a number or a boolean — the scalars `meta` admits
111    /// on the way in — and anything else is refused by the backend rather
112    /// than matched against nothing: a `null` here could only ever mean
113    /// "select nothing", and that already has a spelling.
114    pub meta: Option<Vec<(String, Value)>>,
115}
116
117impl Filter {
118    pub fn all() -> Self {
119        Filter::default()
120    }
121
122    pub fn kinds<I, S>(kinds: I) -> Self
123    where
124        I: IntoIterator<Item = S>,
125        S: Into<String>,
126    {
127        Filter {
128            kinds: Some(kinds.into_iter().map(Into::into).collect()),
129            ..Filter::default()
130        }
131    }
132
133    /// Require `key` under `meta` to hold `value`.
134    ///
135    /// Adds to whatever pairs were there — every pair must hold — so
136    /// `.meta("tenant", "a").meta("closed", true)` reads as "both", which is
137    /// the reading the plural axis has. Contrast [`Filter::stream`], which
138    /// replaces: one name is a singular claim, a property list is not.
139    pub fn meta(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
140        self.meta
141            .get_or_insert_with(Vec::new)
142            .push((key.into(), value.into()));
143        self
144    }
145
146    /// Whether every `meta` value is a scalar this filter can match.
147    ///
148    /// For a backend to call before it builds a query. The check is here
149    /// rather than in [`Filter::meta`] so that a filter assembled by hand
150    /// from its public fields is held to the same rule as one built through
151    /// the method.
152    pub fn validate(&self) -> Result<()> {
153        for (key, value) in self.meta.iter().flatten() {
154            match value {
155                Value::String(_) | Value::Number(_) | Value::Bool(_) => {}
156                Value::Null => {
157                    return Err(Error::validation(format!(
158                        "a filter on `{FIELD_META}.{key}` cannot match `null`: a key the \
159                         event does not carry matches nothing already, and an empty \
160                         `kinds` or `streams` is how to select nothing on purpose"
161                    )))
162                }
163                other => {
164                    return Err(Error::validation(format!(
165                        "a filter on `{FIELD_META}.{key}` must be a string, number or \
166                         boolean, found {}; `{FIELD_META}` holds scalars, so there is \
167                         nothing structured there to match",
168                        crate::event::type_name(other)
169                    )))
170                }
171            }
172        }
173        Ok(())
174    }
175
176    /// Restrict to one stream.
177    ///
178    /// Replaces whatever set was there, rather than adding to it: reading it
179    /// as "and also this one" would make `.stream("a").stream("b")` mean
180    /// something no reader would guess from the singular name.
181    pub fn stream(mut self, stream: impl Into<String>) -> Self {
182        self.streams = Some(vec![stream.into()]);
183        self
184    }
185
186    /// Restrict to a set of streams.
187    ///
188    /// An empty set selects nothing, which is what "include these" given none
189    /// says. A caller assembling a set from somewhere that can legitimately
190    /// come back empty should check before asking.
191    pub fn streams<I, S>(mut self, streams: I) -> Self
192    where
193        I: IntoIterator<Item = S>,
194        S: Into<String>,
195    {
196        self.streams = Some(streams.into_iter().map(Into::into).collect());
197        self
198    }
199
200    /// Whether this filter can match anything at all. An empty list of either
201    /// kind cannot, and a backend can skip the query entirely.
202    pub fn selects_nothing(&self) -> bool {
203        self.kinds.as_ref().is_some_and(|kinds| kinds.is_empty())
204            || self
205                .streams
206                .as_ref()
207                .is_some_and(|streams| streams.is_empty())
208    }
209}
210
211#[async_trait]
212pub trait EventLog: Send + Sync {
213    /// A handle on one stream. Everything below this is the per-stream SPI,
214    /// unchanged.
215    async fn stream(&self, id: &str) -> Result<Box<dyn EventStore>>;
216
217    /// Events with `position > from`, across every stream, in position order,
218    /// at most `limit`.
219    ///
220    /// Exclusive on `from` so a cursor can be fed straight back in:
221    /// [`Position::BEGINNING`] reads from the start, and the position of the
222    /// last event handled reads the next batch.
223    async fn read_all(
224        &self,
225        from: Position,
226        filter: &Filter,
227        limit: usize,
228    ) -> Result<Vec<Recorded>>;
229
230    /// The newest position in the log, or [`Position::BEGINNING`] if it is
231    /// empty.
232    async fn head_position(&self) -> Result<Position>;
233
234    /// Catch up from `from`, then stay live.
235    ///
236    /// There is no seam a consumer has to handle: the live tail is the same
237    /// range read, resumed. Ordering is by position and nothing is skipped —
238    /// see [`Position`] for why that holds without gap detection.
239    ///
240    /// How the live half learns of a write is the backend's business. The
241    /// SQLite backend wakes subscribers on **the same log** directly, and
242    /// falls back to polling for anything else, because SQLite has no
243    /// notification a writer elsewhere could send.
244    ///
245    /// "Anything else" includes a second log opened on the same file in this
246    /// same process — the wake-up channel belongs to the log, not to the
247    /// database. Nothing is lost either way; the difference is latency, and it
248    /// is about three orders of magnitude [measured: 552µs woken directly
249    /// against 552ms on a 600ms poll, `tests/two_logs.rs`]. Open the file once
250    /// per process and share the log if that matters.
251    fn subscribe(
252        &self,
253        from: Position,
254        filter: Filter,
255    ) -> Result<BoxStream<'static, Result<Recorded>>>;
256
257    /// How far `consumer` has got, or [`Position::BEGINNING`] if it has never
258    /// reported.
259    async fn checkpoint_load(&self, consumer: &str) -> Result<Position>;
260
261    /// Record how far `consumer` has got.
262    ///
263    /// Callers that need the checkpoint to move in the same transaction as
264    /// the work it accounts for must not use this — it is its own write. That
265    /// is what a projection runner is for.
266    async fn checkpoint_save(&self, consumer: &str, at: Position) -> Result<()>;
267
268    /// Read events out in position order, **as they are stored**.
269    ///
270    /// The one read that does not run the upcaster chain. Every other read
271    /// wants the current shape; a transfer wants the bytes, so the receiving
272    /// log can hold exactly what this one held and run its own chain over
273    /// them. Upcasting on the way out would bake this build's reading of an
274    /// old event into the copy and lose the original.
275    ///
276    /// Page with `from` and `limit`, feeding the last returned position back
277    /// in; a short batch is the end. See [`crate::transfer`] for what travels
278    /// and what the receiving log reassigns.
279    ///
280    /// The default declines, because a log with no stored form has nothing to
281    /// hand over that another log could hold.
282    async fn export(
283        &self,
284        from: Position,
285        filter: &Filter,
286        limit: usize,
287    ) -> Result<Vec<ExportedEvent>> {
288        let _ = (from, filter, limit);
289        Err(Error::Unsupported(
290            "this log cannot hand over its stored events".to_string(),
291        ))
292    }
293
294    /// Write exported events into this log, in the order given.
295    ///
296    /// `seq` and `position` are this log's to assign; everything else travels
297    /// unchanged, `epoch_ms` and `_schema_version` included. Keeping the
298    /// version is what leaves an old event within reach of the upcaster
299    /// written for it, and
300    /// [`ImportReport::reproduced_coordinates`] reports whether the batch
301    /// landed where it came from, so a migration can check rather than assume.
302    ///
303    /// The default declines rather than appending one at a time. A backend
304    /// with no transaction could only offer a partial import, and a transfer
305    /// that stopped half way is worse than one that refused: from the outside
306    /// there is no way to tell how far it got.
307    async fn import(&self, events: Vec<ExportedEvent>) -> Result<ImportReport> {
308        let _ = events;
309        Err(Error::Unsupported(
310            "this log cannot take in exported events as one write".to_string(),
311        ))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use serde_json::json;
319
320    #[test]
321    fn meta_pairs_accumulate_and_validate_as_scalars() {
322        let filter = Filter::all().meta("tenant", "a").meta("closed", true);
323        assert_eq!(
324            filter.meta,
325            Some(vec![
326                ("tenant".to_string(), json!("a")),
327                ("closed".to_string(), json!(true)),
328            ])
329        );
330        assert!(filter.validate().is_ok());
331        assert!(
332            !filter.selects_nothing(),
333            "a meta pair is a condition, not a set"
334        );
335    }
336
337    #[test]
338    fn a_null_or_structured_meta_value_fails_validation() {
339        for value in [json!(null), json!([1]), json!({ "id": 1 })] {
340            let err = Filter::all().meta("k", value).validate().unwrap_err();
341            assert!(matches!(err, Error::Validation(_)), "{err}");
342        }
343    }
344
345    #[test]
346    fn an_empty_meta_list_is_no_condition() {
347        let filter = Filter {
348            meta: Some(Vec::new()),
349            ..Filter::default()
350        };
351        assert!(!filter.selects_nothing());
352        assert!(filter.validate().is_ok());
353    }
354}