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}