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
13use async_trait::async_trait;
14use futures_core::stream::BoxStream;
15
16use crate::error::{Error, Result};
17use crate::position::{Position, Recorded};
18use crate::store::EventStore;
19use crate::transfer::{ExportedEvent, ImportReport};
20
21/// Which events a cross-stream read or a subscription wants.
22///
23/// Both filters are matched on the **stored** kind and the stored stream
24/// name, before the upcaster chain runs — the same rule
25/// [`EventStore::read_kinds`] follows, for the same reason.
26#[derive(Debug, Clone, Default)]
27pub struct Filter {
28    /// Kinds to include. `None` includes every kind; an empty vector selects
29    /// nothing, which is the honest reading of "include these" given none.
30    pub kinds: Option<Vec<String>>,
31    /// Streams to include. `None` reads every stream; an empty vector selects
32    /// nothing, the same reading `kinds` gets.
33    ///
34    /// **A set, not one name.** Restricting to a single stream is the common
35    /// case and [`Filter::stream`] still spells it, but the question a caller
36    /// actually has is often about a *group* — the streams of one session, of
37    /// one tenant, of one run. Given only a single-stream filter that has to
38    /// be answered with one read per stream and a merge in the caller, which
39    /// loses the position order the log exists to provide.
40    pub streams: Option<Vec<String>>,
41}
42
43impl Filter {
44    pub fn all() -> Self {
45        Filter::default()
46    }
47
48    pub fn kinds<I, S>(kinds: I) -> Self
49    where
50        I: IntoIterator<Item = S>,
51        S: Into<String>,
52    {
53        Filter {
54            kinds: Some(kinds.into_iter().map(Into::into).collect()),
55            streams: None,
56        }
57    }
58
59    /// Restrict to one stream.
60    ///
61    /// Replaces whatever set was there, rather than adding to it: reading it
62    /// as "and also this one" would make `.stream("a").stream("b")` mean
63    /// something no reader would guess from the singular name.
64    pub fn stream(mut self, stream: impl Into<String>) -> Self {
65        self.streams = Some(vec![stream.into()]);
66        self
67    }
68
69    /// Restrict to a set of streams.
70    ///
71    /// An empty set selects nothing, which is what "include these" given none
72    /// says. A caller assembling a set from somewhere that can legitimately
73    /// come back empty should check before asking.
74    pub fn streams<I, S>(mut self, streams: I) -> Self
75    where
76        I: IntoIterator<Item = S>,
77        S: Into<String>,
78    {
79        self.streams = Some(streams.into_iter().map(Into::into).collect());
80        self
81    }
82
83    /// Whether this filter can match anything at all. An empty list of either
84    /// kind cannot, and a backend can skip the query entirely.
85    pub fn selects_nothing(&self) -> bool {
86        self.kinds.as_ref().is_some_and(|kinds| kinds.is_empty())
87            || self
88                .streams
89                .as_ref()
90                .is_some_and(|streams| streams.is_empty())
91    }
92}
93
94#[async_trait]
95pub trait EventLog: Send + Sync {
96    /// A handle on one stream. Everything below this is the per-stream SPI,
97    /// unchanged.
98    async fn stream(&self, id: &str) -> Result<Box<dyn EventStore>>;
99
100    /// Events with `position > from`, across every stream, in position order,
101    /// at most `limit`.
102    ///
103    /// Exclusive on `from` so a cursor can be fed straight back in:
104    /// [`Position::BEGINNING`] reads from the start, and the position of the
105    /// last event handled reads the next batch.
106    async fn read_all(
107        &self,
108        from: Position,
109        filter: &Filter,
110        limit: usize,
111    ) -> Result<Vec<Recorded>>;
112
113    /// The newest position in the log, or [`Position::BEGINNING`] if it is
114    /// empty.
115    async fn head_position(&self) -> Result<Position>;
116
117    /// Catch up from `from`, then stay live.
118    ///
119    /// There is no seam a consumer has to handle: the live tail is the same
120    /// range read, resumed. Ordering is by position and nothing is skipped —
121    /// see [`Position`] for why that holds without gap detection.
122    ///
123    /// How the live half learns of a write is the backend's business. The
124    /// SQLite backend wakes subscribers on **the same log** directly, and
125    /// falls back to polling for anything else, because SQLite has no
126    /// notification a writer elsewhere could send.
127    ///
128    /// "Anything else" includes a second log opened on the same file in this
129    /// same process — the wake-up channel belongs to the log, not to the
130    /// database. Nothing is lost either way; the difference is latency, and it
131    /// is about three orders of magnitude [measured: 552µs woken directly
132    /// against 552ms on a 600ms poll, `tests/two_logs.rs`]. Open the file once
133    /// per process and share the log if that matters.
134    fn subscribe(
135        &self,
136        from: Position,
137        filter: Filter,
138    ) -> Result<BoxStream<'static, Result<Recorded>>>;
139
140    /// How far `consumer` has got, or [`Position::BEGINNING`] if it has never
141    /// reported.
142    async fn checkpoint_load(&self, consumer: &str) -> Result<Position>;
143
144    /// Record how far `consumer` has got.
145    ///
146    /// Callers that need the checkpoint to move in the same transaction as
147    /// the work it accounts for must not use this — it is its own write. That
148    /// is what a projection runner is for.
149    async fn checkpoint_save(&self, consumer: &str, at: Position) -> Result<()>;
150
151    /// Read events out in position order, **as they are stored**.
152    ///
153    /// The one read that does not run the upcaster chain. Every other read
154    /// wants the current shape; a transfer wants the bytes, so the receiving
155    /// log can hold exactly what this one held and run its own chain over
156    /// them. Upcasting on the way out would bake this build's reading of an
157    /// old event into the copy and lose the original.
158    ///
159    /// Page with `from` and `limit`, feeding the last returned position back
160    /// in; a short batch is the end. See [`crate::transfer`] for what travels
161    /// and what the receiving log reassigns.
162    ///
163    /// The default declines, because a log with no stored form has nothing to
164    /// hand over that another log could hold.
165    async fn export(
166        &self,
167        from: Position,
168        filter: &Filter,
169        limit: usize,
170    ) -> Result<Vec<ExportedEvent>> {
171        let _ = (from, filter, limit);
172        Err(Error::Unsupported(
173            "this log cannot hand over its stored events".to_string(),
174        ))
175    }
176
177    /// Write exported events into this log, in the order given.
178    ///
179    /// `seq` and `position` are this log's to assign; everything else travels
180    /// unchanged, `epoch_ms` and `_schema_version` included. Keeping the
181    /// version is what leaves an old event within reach of the upcaster
182    /// written for it, and
183    /// [`ImportReport::reproduced_coordinates`] reports whether the batch
184    /// landed where it came from, so a migration can check rather than assume.
185    ///
186    /// The default declines rather than appending one at a time. A backend
187    /// with no transaction could only offer a partial import, and a transfer
188    /// that stopped half way is worse than one that refused: from the outside
189    /// there is no way to tell how far it got.
190    async fn import(&self, events: Vec<ExportedEvent>) -> Result<ImportReport> {
191        let _ = events;
192        Err(Error::Unsupported(
193            "this log cannot take in exported events as one write".to_string(),
194        ))
195    }
196}