Skip to main content

cardbox/store/
mod.rs

1//! The mechanism: one eventsdb log, one blob directory, and the read models built from
2//! the first, exposed to Teal as `require("store")`.
3//!
4//! The host owns the IO, the transaction and the invariant that must hold at the instant a
5//! write lands. Which kinds exist, what a card is called, what a card may be filtered on
6//! are the Teal side's, where changing them costs no rebuild.
7
8pub mod json;
9pub mod projection;
10pub mod transfer;
11
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, MutexGuard};
14
15use eventsdb::sqlite::{ProjectionRunner, SqliteEventLog};
16use eventsdb::{Committed, EventLog, EventStore, Filter, Position};
17use htl::{TealRecord, host_module};
18use serde_json::{Map, Value as Json};
19use sha2::{Digest, Sha256};
20
21pub use json::Value;
22use projection::{ALIAS_PREFIX, CardsProjection, STREAM_PREFIX};
23
24/// Projection names this build has retired, newest last.
25///
26/// A database written by one of them holds every event; what it does not hold is a cursor
27/// this build's projection can use. See [`Store::carry_forward`] for what is done about
28/// that, and [`projection`]'s module doc for why the name is the version.
29const RETIRED: [&str; 3] = ["cards_v1", "cards_v2", "cards_v3"];
30
31/// A Lua string, in and out, as bytes rather than as `&str`.
32///
33/// This is `mlua::BString` (`bstr::BString`) and **not** mlua's own `LuaString` alias,
34/// which names a string mlua owns and which a `#[host_module]` method cannot construct —
35/// building one needs the `&Lua` the generated wrapper keeps to itself. `BString`
36/// converts both ways with no `&Lua` and without demanding UTF-8, which a blob (a
37/// checkpoint, a compressed page) is not.
38///
39/// The *name* is the load-bearing part. htl maps a Rust type to a Teal one by the last
40/// segment of its path, and `LuaString` is one of the three idents it spells `string`.
41/// The value really does become a Lua string, so the generated declaration and the
42/// behaviour agree; the alias only keeps `src/store.d.tl` from naming a Rust type Teal
43/// has never heard of.
44type LuaString = htl::mlua::BString;
45
46/// One event, as Teal reads it: the envelope plus where the store put it.
47#[derive(TealRecord, Clone, Debug)]
48pub struct Recorded {
49    pub stream: String,
50    /// Per-stream sequence, from 1.
51    pub seq: u64,
52    /// Global coordinate. 0 for a backend with no database-wide order; SQLite always has one.
53    pub position: u64,
54    pub epoch_ms: i64,
55    pub kind: String,
56    pub meta: Value,
57    pub data: Value,
58}
59
60/// One blob: the name it is stored under and how big it is.
61#[derive(TealRecord, Clone, Debug)]
62pub struct Blob {
63    /// Lowercase hex of the SHA-256 of the bytes. The file's name, and its identity.
64    pub hash: String,
65    pub size: u64,
66}
67
68/// What one [`Store::export`] wrote.
69///
70/// `file` is absent when there was nothing new: no file is created, `from` and `through`
71/// are both the chain's end, and `events` is 0. An export of nothing is not a file holding
72/// nothing.
73#[derive(TealRecord, Clone, Debug)]
74pub struct ExportReport {
75    pub file: Option<String>,
76    /// The position the export started after — the end of the confirmed chain.
77    pub from: u64,
78    /// The position of the last event written, or `from` when none was.
79    pub through: u64,
80    pub events: u64,
81}
82
83/// What one [`Store::import`] read back in.
84#[derive(TealRecord, Clone, Debug)]
85pub struct ImportReport {
86    pub events: u64,
87    /// Whether every event landed on the position it carried out of the log it came from.
88    /// True for a file imported in order into an empty store, which is what restoring a
89    /// backup is; false when it merged into a store that already had history, which
90    /// renumbers by design.
91    pub reproduced_coordinates: bool,
92}
93
94/// What one [`Store::retain_streams`] removed.
95#[derive(TealRecord, Clone, Debug)]
96pub struct RetainReport {
97    /// Events deleted, as eventsdb's retention ledger recorded them.
98    pub removed: u64,
99    /// How many distinct streams those events were spread over — the streams that actually
100    /// held something, which is at most the number asked for.
101    pub streams: u64,
102}
103
104/// What one [`Store::blob_gc`] deleted.
105#[derive(TealRecord, Clone, Debug)]
106pub struct BlobGcReport {
107    pub deleted: u64,
108    /// The sum of the sizes the projection recorded for them, not a measurement of the
109    /// filesystem: a blob whose file was already gone still counts the size its row held.
110    pub bytes: u64,
111}
112
113/// One eventsdb log, one content-addressed blob directory and one read model, under one
114/// root.
115///
116/// Every method is synchronous. eventsdb's API is `async`, so the store owns a
117/// current-thread runtime and `block_on`s each call on it: Lua has nothing to suspend
118/// into, and a host method that returns a future would be a future nobody polls.
119///
120/// `command` is the single-writer lock, and it is the reason the decisions below can be
121/// trusted. `append_if` makes one stream's fold atomic, but a policy that reads one
122/// stream and then writes another — an alias bound only to a card that exists, which is
123/// what `bind_alias` is — is two calls, and eventsdb cannot make those one. This process is the
124/// only writer of this file, so holding `command` across the pair is what closes that
125/// window. Every write method takes it; `query` takes it too, because catching the read
126/// model up is itself a write.
127pub struct Store {
128    log: SqliteEventLog,
129    rt: tokio::runtime::Runtime,
130    root: PathBuf,
131    command: Mutex<()>,
132    /// The runner for [`CardsProjection`]. Behind its own lock because every method here
133    /// takes `&self` — the host module's methods are called from Lua, which has no way to
134    /// hold a `&mut` — while `run_once` needs `&mut` to move the projection into the
135    /// transaction and back out.
136    cards: Mutex<ProjectionRunner<CardsProjection>>,
137}
138
139impl Store {
140    /// Open the store under `root`, creating `<root>/` and `<root>/blobs/` if they are
141    /// not there. The log is `<root>/cards.db`, and the read model's tables are in it.
142    pub fn open(root: &Path) -> anyhow::Result<Store> {
143        // `time` as well as `rt`: eventsdb backs off with `tokio::time::sleep` when a
144        // write finds the database busy, and a runtime with no timer would panic there
145        // rather than wait.
146        let rt = tokio::runtime::Builder::new_current_thread()
147            .enable_time()
148            .build()?;
149        std::fs::create_dir_all(root)?;
150        std::fs::create_dir_all(root.join("blobs"))?;
151        let log = rt.block_on(SqliteEventLog::open(root.join("cards.db")))?;
152        let mut cards = log.runner(CardsProjection::new())?;
153        // Asked before `init`, because `init` is where the answer changes: it drops the
154        // `cb_*` tables of an older shape and creates this one's, and what it cannot do
155        // from inside its transaction is fold the log back into them.
156        let outdated = Store::shape_outdated(&rt, &log)?;
157        // `init` is idempotent and creates the tables. It runs on every open rather than
158        // on the first one, because "the file exists" is not "the file has this version's
159        // tables in it" — a store opened by an older build has the log and not the model.
160        rt.block_on(cards.init())?;
161        // Whatever the cursors say. A shape change is a rename by convention, and then
162        // `carry_forward` would rebuild anyway; this is for the store where it was not,
163        // whose live cursor would otherwise stand at the head over empty tables.
164        if outdated {
165            rt.block_on(cards.rebuild())?;
166        }
167        Store::carry_forward(&rt, &log, &mut cards)?;
168        Ok(Store {
169            log,
170            rt,
171            root: root.to_path_buf(),
172            command: Mutex::new(()),
173            cards: Mutex::new(cards),
174        })
175    }
176
177    /// Whether the `cb_*` tables on disk predate this build's shape — the same question
178    /// [`projection::shape_outdated`] answers inside `init`, asked through the read-only
179    /// hatch before `init` has run.
180    fn shape_outdated(rt: &tokio::runtime::Runtime, log: &SqliteEventLog) -> anyhow::Result<bool> {
181        for (table, column) in projection::SHAPE_MARKS {
182            let rows = rt.block_on(log.query(&projection::shape_probe(table), Vec::new()))?;
183            let has = rows
184                .iter()
185                .any(|r| r.get("name").and_then(Json::as_str) == Some(column));
186            if !rows.is_empty() && !has {
187                return Ok(true);
188            }
189        }
190        Ok(false)
191    }
192
193    /// Bring a database written under a retired projection name up to this one.
194    ///
195    /// The mechanism eventsdb gives is the checkpoint, keyed by the projection's name, and
196    /// the whole of the migration is which name has one: a retired name with a cursor and
197    /// a live name without is a file this build has not folded yet. The `cb_*` tables it
198    /// finds there were written by the old fold, so they are emptied and replayed rather
199    /// than added to — `rebuild()` does the reset and the rewind in one transaction, and
200    /// the counters this model keeps (`sample_rows`, `eval_count`, `cb_blobs.refs`) would
201    /// otherwise be counted a second time for every event the old cursor had already seen.
202    ///
203    /// **What this cannot do is remove the retired row.** `checkpoints` is reserved
204    /// against writes and the only cursor API is load and save — eventsdb 0.5 has no
205    /// "forget this consumer". So the retired name is dragged up to where the live model
206    /// stands instead. That is not cosmetic: retention names the consumer with the lowest
207    /// cursor and refuses to remove past it (`Error::ConsumerBehind`), so a row parked at
208    /// an old head would become the thing that blocks the prune — on behalf of a
209    /// reader that does not exist. It is dragged on every open rather than only on the
210    /// migration, because the log grows between opens and that row does not.
211    ///
212    /// **An open is not enough on its own.** The drag leaves the retired cursor where the
213    /// live model stood *then*, and every append after it leaves the row behind again — so
214    /// a store opened, written to and pruned in the one process would hit exactly the
215    /// `ConsumerBehind` this exists to prevent. [`Store::retain_streams`] runs it again
216    /// immediately before the delete, on the same reasoning and for the same row.
217    fn carry_forward(
218        rt: &tokio::runtime::Runtime,
219        log: &SqliteEventLog,
220        cards: &mut ProjectionRunner<CardsProjection>,
221    ) -> anyhow::Result<()> {
222        let live = rt.block_on(log.checkpoint_load(CardsProjection::NAME))?;
223        let mut rebuilt = false;
224        for retired in RETIRED {
225            // A checkpoint is written only once a consumer has passed something, so a
226            // cursor still at the beginning means there is no row — and no row means no
227            // database was ever folded under that name. Saving one would *create* the
228            // consumer this method exists to keep from being a problem.
229            let at = rt.block_on(log.checkpoint_load(retired))?;
230            if at == Position::BEGINNING {
231                continue;
232            }
233            if live == Position::BEGINNING && !rebuilt {
234                rt.block_on(cards.rebuild())?;
235                rebuilt = true;
236            }
237            let now = rt.block_on(cards.position())?;
238            if at < now {
239                rt.block_on(log.checkpoint_save(retired, now))?;
240            }
241        }
242        Ok(())
243    }
244
245    /// The `card_opened` of `card_id`, or nothing if no card was opened under that id.
246    ///
247    /// One row: the decision that writes a `card_opened` is `unwritten`, so there is at
248    /// most one, and the filter reads it through the `(kind, position)` index rather than
249    /// through the card's whole stream.
250    fn card_opened(&self, card_id: &str) -> anyhow::Result<Option<Recorded>> {
251        let stream = format!("{STREAM_PREFIX}{card_id}");
252        let filter = Filter::kinds(["card_opened"]).streams([stream.as_str()]);
253        let page = self
254            .rt
255            .block_on(self.log.read_all(Position::BEGINNING, &filter, 1))?;
256        Ok(page.into_iter().next().map(recorded_from))
257    }
258
259    /// Append `{kind, meta, data}` to `stream` if `rule` says so. The caller holds
260    /// `command`.
261    fn decided(
262        &self,
263        stream: &str,
264        rule: Rule,
265        kind: &str,
266        meta: Json,
267        data: Json,
268    ) -> anyhow::Result<Option<Recorded>> {
269        let event = envelope(kind, meta, data);
270        let written = event.clone();
271        let kinds = rule.kinds();
272        let decide: eventsdb::Decision = Box::new(move |seen| rule.allows(seen).then_some(written));
273        let mut handle = self.log.stream_handle(stream);
274        let committed = self.rt.block_on(handle.append_if(kinds, decide))?;
275        Ok(committed.map(|c| recorded_of(stream, &event, c)))
276    }
277
278    /// The write lock. Poisoning is ignored on purpose: the guard protects an ordering
279    /// between calls, not an invariant held in memory, and a Lua error raised under it
280    /// leaves the log exactly as consistent as eventsdb's own transaction left it.
281    fn command(&self) -> MutexGuard<'_, ()> {
282        self.command.lock().unwrap_or_else(|e| e.into_inner())
283    }
284
285    fn runner(&self) -> MutexGuard<'_, ProjectionRunner<CardsProjection>> {
286        self.cards.lock().unwrap_or_else(|e| e.into_inner())
287    }
288
289    /// Fold everything the read model has not seen. The caller already holds `command`;
290    /// `Mutex` is not reentrant, so taking it again here would deadlock the process.
291    fn caught_up(&self) -> anyhow::Result<u64> {
292        let mut runner = self.runner();
293        Ok(self.rt.block_on(runner.catch_up())? as u64)
294    }
295
296    fn blobs(&self) -> PathBuf {
297        self.root.join("blobs")
298    }
299}
300
301/// Exposed to Teal as `require("store")`. Its declaration is written to `src/store.d.tl`
302/// by this macro at build time, and by `htl dts` / `htl check` without building.
303///
304/// `errors = "return"`: every fallible method comes back Lua-style, `value, err`. An
305/// `Option` return then has three answers rather than two, which `append_if` needs —
306/// `rec, nil` wrote, `nil, err` failed, and `nil, nil` is the decision declining.
307#[host_module(name = "store", dts = "src/store.d.tl", errors = "return", records = [Recorded, Blob, ExportReport, ImportReport, RetainReport, BlobGcReport])]
308impl Store {
309    /// Append `{kind, meta, data}` to `stream`.
310    ///
311    /// A null `meta` or `data` is left out of the envelope rather than written as JSON
312    /// null: eventsdb's contract is that those two keys are optional and scalars-only /
313    /// any-depth respectively, and `null` is neither absent nor a value it wants.
314    pub fn append(
315        &self,
316        stream: &str,
317        kind: &str,
318        meta: Value,
319        data: Value,
320    ) -> anyhow::Result<Recorded> {
321        let _lock = self.command();
322        let event = envelope(kind, meta.0, data.0);
323        let mut handle = self.log.stream_handle(stream);
324        let committed = self.rt.block_on(handle.append(event.clone()))?;
325        Ok(recorded_of(stream, &event, committed))
326    }
327
328    /// Append `{kind, meta, data}` only if `decision`, folded over `stream` inside the
329    /// write, says so. Returns the event when it wrote and nothing when it declined.
330    ///
331    /// `decision` names one of a fixed set built here. Teal never passes code: a
332    /// decision runs while the log holds its write lock, and a callback into Lua from
333    /// there would put an interpreter this host does not control inside eventsdb's
334    /// transaction. Teal chooses; Rust decides.
335    ///
336    /// On the Lua side the three outcomes are `rec, nil` (written), `nil, err` (failed)
337    /// and `nil, nil` (declined) — so `if rec == nil and err == nil then` is the test for
338    /// a decision that found nothing to do.
339    pub fn append_if(
340        &self,
341        stream: &str,
342        decision: &str,
343        kind: &str,
344        meta: Value,
345        data: Value,
346    ) -> anyhow::Result<Option<Recorded>> {
347        let _lock = self.command();
348        self.decided(stream, Rule::parse(decision)?, kind, meta.0, data.0)
349    }
350
351    /// Bind `name` to `card_id`, if that card was opened and the name does not already
352    /// mean it.
353    ///
354    /// **Why this is a method and not another decision string.** The invariant — an alias
355    /// points only at a card that exists — spans two streams, and `append_if` folds one.
356    /// So this is two calls: read `card-<card_id>` for its `card_opened`, then `append_if`
357    /// on `alias-<name>`. What makes the pair atomic is `command`, held across both, and
358    /// what makes that enough is that this process is the only writer of this file — the
359    /// design's reservation stream, and the BP note that a single local writer is a
360    /// legitimate answer to a cross-aggregate uniqueness rule rather than a shortcut.
361    /// Exposing an alias decision through `append_if` would let Teal make the second call
362    /// without the first, which is exactly the dangling alias this step is for.
363    ///
364    /// `Err` when no card was opened under `card_id`. `Ok(None)` — the decision declining
365    /// — when the name already means that card: a rebind to where the alias already points
366    /// asks for a state that holds, so it is idempotent and writes nothing. Rebinding to a
367    /// *different* card appends another `alias_bound` on the same stream, which is what
368    /// keeps the history: nothing is overwritten and nothing has to be released first.
369    pub fn bind_alias(
370        &self,
371        name: &str,
372        card_id: &str,
373        note: Option<String>,
374    ) -> anyhow::Result<Option<Recorded>> {
375        let _lock = self.command();
376        let Some(opened) = self.card_opened(card_id)? else {
377            return Err(anyhow::anyhow!("no card {card_id}"));
378        };
379        let mut meta = Map::new();
380        meta.insert("card_id".to_string(), Json::String(card_id.to_string()));
381        // The pkg is the card's own, read off the event that opened it, so `alias_list`
382        // can answer "the aliases in this pkg" out of the alias rows alone. Taking it from
383        // the caller would let the two disagree about one thing.
384        if let Some(pkg) = opened.meta.0.get("pkg").filter(|p| p.is_string()) {
385            meta.insert("pkg".to_string(), pkg.clone());
386        }
387        self.decided(
388            &alias_stream(name),
389            Rule::AliasNot(card_id.to_string()),
390            "alias_bound",
391            Json::Object(meta),
392            note_data(note),
393        )
394    }
395
396    /// Release `name`, if it currently means anything. `Ok(None)` when it does not.
397    ///
398    /// The event carries the card_id it released, so the history reads without a join and
399    /// a rebuild can tell "released from A" from "released from B".
400    pub fn release_alias(
401        &self,
402        name: &str,
403        note: Option<String>,
404    ) -> anyhow::Result<Option<Recorded>> {
405        let _lock = self.command();
406        let stream = alias_stream(name);
407        let data = note_data(note);
408        // The card_id comes out of the same fold that decides, not out of a read before
409        // it: the fold runs while the log holds the write lock, so the id the event
410        // carries is the binding that was there when it was released. The cell is how the
411        // finished event gets back here — a `Decision` is `FnOnce` and hands what it built
412        // to eventsdb rather than to its caller.
413        let captured: Arc<Mutex<Option<Map<String, Json>>>> = Arc::default();
414        let sink = Arc::clone(&captured);
415        let decide: eventsdb::Decision = Box::new(move |seen| {
416            let card_id = bound_to(seen)?;
417            let mut meta = Map::new();
418            meta.insert("card_id".to_string(), Json::String(card_id));
419            let event = envelope("alias_released", Json::Object(meta), data);
420            *sink.lock().unwrap_or_else(|e| e.into_inner()) = Some(event.clone());
421            Some(event)
422        });
423        let mut handle = self.log.stream_handle(&stream);
424        let committed = self
425            .rt
426            .block_on(handle.append_if(Rule::AliasBound.kinds(), decide))?;
427        let Some(at) = committed else {
428            return Ok(None);
429        };
430        let event = captured
431            .lock()
432            .unwrap_or_else(|e| e.into_inner())
433            .take()
434            .ok_or_else(|| {
435                anyhow::anyhow!(
436                    "an alias_released landed on {stream} that this store did not build"
437                )
438            })?;
439        Ok(Some(recorded_of(&stream, &event, at)))
440    }
441
442    /// The whole of `stream` in `seq` order, optionally only `kinds`.
443    ///
444    /// Paged rather than read at once: `read_all` is one page and a cursor, so this loops
445    /// until a page comes back short. Nothing is held between pages.
446    pub fn read_stream(
447        &self,
448        stream: &str,
449        kinds: Option<Vec<String>>,
450    ) -> anyhow::Result<Vec<Recorded>> {
451        const PAGE: usize = 512;
452        let filter = match &kinds {
453            Some(k) => Filter::kinds(k.iter().map(String::as_str)),
454            None => Filter::all(),
455        }
456        .streams([stream]);
457        let mut out = Vec::new();
458        let mut from = Position::BEGINNING;
459        loop {
460            let page = self.rt.block_on(self.log.read_all(from, &filter, PAGE))?;
461            let short = page.len() < PAGE;
462            for stored in page {
463                from = stored.position;
464                out.push(recorded_from(stored));
465            }
466            if short {
467                break;
468            }
469        }
470        Ok(out)
471    }
472
473    /// The escape hatch: read-only SQL over the log, the read model's tables and anything
474    /// else beside them.
475    ///
476    /// **Read-your-writes.** The projection is caught up first, under the same lock a
477    /// write takes, so a Teal `find` that runs a line after a `close` sees the closed
478    /// card. Without that the read model would be eventually consistent, which for a
479    /// single-process store is a cost with nothing bought by it: the only writer is this
480    /// process, so "everything written" is a state this call can reach rather than wait
481    /// for. A caught-up projection costs one empty batch read when there is nothing to do.
482    ///
483    /// `params` binds by position (`?1`, `?2`, …). Rows come back as JSON objects, one
484    /// per row, so Teal sees a table per row keyed by column name.
485    pub fn query(&self, sql: &str, params: Vec<Value>) -> anyhow::Result<Vec<Value>> {
486        let _lock = self.command();
487        self.caught_up()?;
488        let bound: Vec<Json> = params.into_iter().map(|v| v.0).collect();
489        let rows = self.rt.block_on(self.log.query(sql, bound))?;
490        Ok(rows.into_iter().map(|r| Value(Json::Object(r))).collect())
491    }
492
493    /// Fold everything the read model has not seen yet, and say how many events that was.
494    ///
495    /// `query` does this on its own, so nothing needs to call it to read correctly. It is
496    /// here for the two cases where the number is the point: a batch job that wants the
497    /// model warm before it starts timing, and a test that wants to prove a read did not
498    /// need it.
499    pub fn catch_up(&self) -> anyhow::Result<u64> {
500        let _lock = self.command();
501        self.caught_up()
502    }
503
504    /// Empty the read model and replay the log into it, returning the events applied.
505    ///
506    /// For a fold that changed without its tables changing shape. When the *shape* changes
507    /// incompatibly the move is to rename the projection (`cards_v1` → `cards_v2`), which
508    /// gives the new model its own cursor and leaves the old one readable until the switch.
509    pub fn rebuild(&self) -> anyhow::Result<u64> {
510        let _lock = self.command();
511        let mut runner = self.runner();
512        Ok(self.rt.block_on(runner.rebuild())? as u64)
513    }
514
515    /// Store `bytes` under the hex of their SHA-256 and return the name.
516    ///
517    /// Content-addressed, so it is idempotent by construction: the same bytes are the
518    /// same file, and a second put of them writes nothing. The write goes to a temporary
519    /// name in the same directory and is renamed into place, so a reader never sees a
520    /// half-written blob under a hash that promises the whole of it.
521    pub fn blob_put(&self, bytes: LuaString) -> anyhow::Result<Blob> {
522        let _lock = self.command();
523        let hash = hex::encode(Sha256::digest(&bytes[..]));
524        let size = bytes.len() as u64;
525        let path = self.blobs().join(&hash);
526        if path.exists() {
527            return Ok(Blob { hash, size });
528        }
529        let tmp = self.blobs().join(format!(".{hash}.{}", std::process::id()));
530        std::fs::write(&tmp, &bytes[..])?;
531        std::fs::rename(&tmp, &path)?;
532        Ok(Blob { hash, size })
533    }
534
535    /// The bytes stored under `hash`, or nothing if no blob has that name.
536    pub fn blob_get(&self, hash: &str) -> anyhow::Result<Option<LuaString>> {
537        let path = self.blobs().join(hash);
538        match std::fs::read(&path) {
539            Ok(bytes) => Ok(Some(LuaString::from(bytes))),
540            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
541            Err(e) => Err(e.into()),
542        }
543    }
544
545    /// Where the blob named `hash` lives, whether or not it is there yet.
546    pub fn blob_path(&self, hash: &str) -> String {
547        self.blobs().join(hash).display().to_string()
548    }
549
550    /// `v` as JSON text.
551    ///
552    /// Here because Teal has no JSON of its own, and because the policy side needs to
553    /// *weigh* a value before deciding where to put it: a batch of sample rows is inlined
554    /// or blobbed on the length of exactly this text. The conversion is the one every
555    /// other method on this store uses, so what is measured here is what would be stored.
556    pub fn json_encode(&self, v: Value) -> anyhow::Result<String> {
557        Ok(serde_json::to_string(&v.0)?)
558    }
559
560    /// `text`, parsed. The other direction, for reading back what `blob_put` was handed:
561    /// a blob is bytes to this store and JSON only to whoever wrote it.
562    /// A short, stable fingerprint of a JSON value: the first 16 hex digits of the SHA-256
563    /// of its canonical text, where canonical means every object's keys are in sorted
564    /// order and nothing is pretty-printed.
565    ///
566    /// Here rather than in Teal because Teal has no hash, and canonical because two runs
567    /// that were given the same params should print the same whatever order their tables
568    /// happened to be walked in. What goes *into* the fingerprint is the policy side's
569    /// call — `cards.open` hands it `params` and nothing else — and this only answers what
570    /// those bytes are called.
571    pub fn digest(&self, v: Value) -> String {
572        let canonical = canonical_json(&v.0);
573        let hash = Sha256::digest(canonical.as_bytes());
574        hex::encode(&hash[..8])
575    }
576
577    pub fn json_decode(&self, text: &str) -> anyhow::Result<Value> {
578        Ok(Value(serde_json::from_str(text)?))
579    }
580
581    /// Write everything the log holds past the end of the confirmed export chain to one
582    /// JSON Lines file under `<root>/export/`, and confirm it.
583    ///
584    /// This is the half of a prune that has to happen first, and the whole log is what it
585    /// takes: `Guard::Exported` chains the confirmed **unfiltered** receipts from position
586    /// 0 and refuses to remove past the chain's end, so an export of only the streams being
587    /// pruned would leave the chain — and therefore the guard — exactly where it was. See
588    /// [`transfer`] for the order and the reasoning; what the directory ends up being is an
589    /// append-only backup of the log, one file per call, which is what `import` reads.
590    ///
591    /// Nothing new is not an error and not an empty file: `file` comes back absent and
592    /// `events` is 0.
593    pub fn export(&self) -> anyhow::Result<ExportReport> {
594        let _lock = self.command();
595        self.run_export()
596    }
597
598    /// Read a JSON Lines file written by [`Store::export`] back into this log, and catch
599    /// the read models up.
600    ///
601    /// `seq` and `position` are this log's to assign; `kind`, `meta`, `data`, `epoch_ms`
602    /// and `_schema_version` travel unchanged. `reproduced_coordinates` says whether every
603    /// event landed back on the position it carried, which is true for a file imported in
604    /// order into an empty store — the check that a restore really is the same log rather
605    /// than the same events.
606    pub fn import(&self, path: &str) -> anyhow::Result<ImportReport> {
607        let _lock = self.command();
608        self.run_import(path)
609    }
610
611    /// Remove every event of `streams`, if the exports vouch for them and no read model
612    /// would be left behind, and give the freed pages back to the filesystem.
613    ///
614    /// `Guard::Exported`, never `Force`: the one operation here that can make a correct
615    /// read wrong is the one operation that asks permission. Both refusals come back as
616    /// errors that say what to do — run an export, or catch the named consumer up.
617    ///
618    /// `removed` counts events and `streams` counts the streams they were spread over,
619    /// which is at most the number asked for: a stream with nothing on it is not an error
620    /// and is not counted.
621    pub fn retain_streams(&self, streams: Vec<String>) -> anyhow::Result<RetainReport> {
622        let _lock = self.command();
623        self.run_retain(streams)
624    }
625
626    /// Delete every blob nothing points at any more, and the row that counted the
627    /// pointers.
628    ///
629    /// `cb_blobs.refs` is the projection's count — one per `samples_appended` or
630    /// `checkpoint_saved` naming the hash, one back per card the prune journal removed —
631    /// so a blob two cards share survives the first of them going. `cb_blobs` is this
632    /// crate's table rather than eventsdb's, which is why the hatch lets the row be
633    /// deleted at all.
634    pub fn blob_gc(&self) -> anyhow::Result<BlobGcReport> {
635        let _lock = self.command();
636        self.run_blob_gc()
637    }
638
639    /// The directory this store was opened on.
640    pub fn root(&self) -> String {
641        self.root.display().to_string()
642    }
643}
644
645// ------------------------------------------------------------------ decisions
646
647/// The fixed set of folds `append_if` will run.
648///
649/// Each one is a question about a stream that has to be answered at the instant the
650/// write lands, and each names the kinds it needs: the fold is shown only those, which is
651/// the difference between reading a long stream and reading three events of it.
652///
653/// The first four are what `append_if` will look up by name. The two alias folds are
654/// not: they are reached only through [`Store::bind_alias`] and [`Store::release_alias`],
655/// because a bind that skipped the card read those methods do first is the bug the whole
656/// step is about. Teal chooses between the methods; it cannot assemble one.
657#[derive(Clone)]
658enum Rule {
659    /// Nothing has been recorded on this stream yet.
660    Unwritten,
661    /// A `card_opened` is on the stream and no `card_closed` is.
662    OpenUnclosed,
663    /// No `card_closed` is on the stream. The fold a close itself runs.
664    ClosedAbsent,
665    /// A `card_opened` is on the stream, whatever came after it. The fold an assessment
666    /// and a tag run: those are said *about* a run, not produced by it, so a close does
667    /// not end them the way it ends samples and checkpoints.
668    Opened,
669    /// This alias does not currently mean this card — either it means another one or it
670    /// means nothing. The fold a bind runs.
671    AliasNot(String),
672    /// This alias currently means something. The fold a release runs.
673    AliasBound,
674}
675
676/// What `alias-<name>` currently means: the card_id of the last `alias_bound` not undone
677/// by an `alias_released`, or nothing.
678///
679/// The fold both alias decisions are, and the one place the current binding is read from
680/// the log rather than from the read model. An `alias_bound` with no `meta.card_id` cannot
681/// be written by this store (`bind_alias` puts the id there), and reads as unbound here
682/// rather than as a binding to nothing; the projection reports the same event as corrupt
683/// when it folds it, which is where a reader would want to hear about it.
684fn bound_to(seen: &[eventsdb::Current]) -> Option<String> {
685    let mut bound = None;
686    for event in seen {
687        match event.kind() {
688            "alias_bound" => {
689                bound = event
690                    .get("meta")
691                    .and_then(|m| m.get("card_id"))
692                    .and_then(Json::as_str)
693                    .map(str::to_string);
694            }
695            "alias_released" => bound = None,
696            _ => {}
697        }
698    }
699    bound
700}
701
702impl Rule {
703    const KNOWN: &'static str = r#""unwritten", "open_unclosed", "closed_absent", "opened""#;
704
705    fn parse(name: &str) -> anyhow::Result<Rule> {
706        match name {
707            "unwritten" => Ok(Rule::Unwritten),
708            "open_unclosed" => Ok(Rule::OpenUnclosed),
709            "closed_absent" => Ok(Rule::ClosedAbsent),
710            "opened" => Ok(Rule::Opened),
711            other => Err(anyhow::anyhow!(
712                "unknown decision {other:?}; the decisions this store knows are {}",
713                Rule::KNOWN
714            )),
715        }
716    }
717
718    /// What the fold is shown. `None` would be the whole stream.
719    fn kinds(&self) -> Option<&'static [&'static str]> {
720        match self {
721            // Unwritten asks whether anything is there, so it cannot name kinds: a
722            // stream holding only events of other kinds would read as empty.
723            Rule::Unwritten => None,
724            Rule::OpenUnclosed => Some(&["card_opened", "card_closed"]),
725            Rule::ClosedAbsent => Some(&["card_closed"]),
726            Rule::Opened => Some(&["card_opened"]),
727            Rule::AliasNot(_) | Rule::AliasBound => Some(&["alias_bound", "alias_released"]),
728        }
729    }
730
731    fn allows(&self, seen: &[eventsdb::Current]) -> bool {
732        match self {
733            Rule::Unwritten => seen.is_empty(),
734            Rule::OpenUnclosed => {
735                seen.iter().any(|e| e.kind() == "card_opened")
736                    && !seen.iter().any(|e| e.kind() == "card_closed")
737            }
738            Rule::ClosedAbsent => !seen.iter().any(|e| e.kind() == "card_closed"),
739            Rule::Opened => seen.iter().any(|e| e.kind() == "card_opened"),
740            Rule::AliasNot(card_id) => bound_to(seen).as_deref() != Some(card_id.as_str()),
741            Rule::AliasBound => bound_to(seen).is_some(),
742        }
743    }
744}
745
746/// `v` as text with every object's keys sorted, so equal values print equal.
747///
748/// serde_json's `Map` is already a `BTreeMap` unless `preserve_order` is on, and a
749/// dependency could turn that on for the whole build without this crate noticing; walking
750/// the value here is what keeps the fingerprint independent of that.
751fn canonical_json(v: &Json) -> String {
752    match v {
753        Json::Object(map) => {
754            let mut keys: Vec<&String> = map.keys().collect();
755            keys.sort();
756            let fields: Vec<String> = keys
757                .into_iter()
758                .map(|k| format!("{}:{}", Json::String(k.clone()), canonical_json(&map[k])))
759                .collect();
760            format!("{{{}}}", fields.join(","))
761        }
762        Json::Array(items) => {
763            let items: Vec<String> = items.iter().map(canonical_json).collect();
764            format!("[{}]", items.join(","))
765        }
766        other => other.to_string(),
767    }
768}
769
770// ------------------------------------------------------------------ envelopes
771
772/// The stream an alias's events live on.
773fn alias_stream(name: &str) -> String {
774    format!("{ALIAS_PREFIX}{name}")
775}
776
777/// A note, as the `data` of an alias event. Absent when there is none, rather than an
778/// object with a null in it.
779fn note_data(note: Option<String>) -> Json {
780    match note {
781        Some(note) => {
782            let mut data = Map::new();
783            data.insert("note".to_string(), Json::String(note));
784            Json::Object(data)
785        }
786        None => Json::Null,
787    }
788}
789
790pub(crate) fn envelope(kind: &str, meta: Json, data: Json) -> Map<String, Json> {
791    let mut event = Map::new();
792    event.insert("kind".to_string(), Json::String(kind.to_string()));
793    if !meta.is_null() {
794        event.insert("meta".to_string(), meta);
795    }
796    if !data.is_null() {
797        event.insert("data".to_string(), data);
798    }
799    event
800}
801
802/// The event as it was written, plus the coordinates the write returned.
803fn recorded_of(stream: &str, event: &Map<String, Json>, at: Committed) -> Recorded {
804    Recorded {
805        stream: stream.to_string(),
806        seq: at.seq,
807        position: at.position.map(|p| p.get()).unwrap_or(0),
808        epoch_ms: at.epoch_ms as i64,
809        kind: field(event, "kind")
810            .as_str()
811            .unwrap_or_default()
812            .to_string(),
813        meta: Value(field(event, "meta")),
814        data: Value(field(event, "data")),
815    }
816}
817
818/// An event read back out of the log.
819fn recorded_from(stored: eventsdb::Recorded) -> Recorded {
820    let position = stored.position.get();
821    let stream = stored.stream;
822    let event = stored.event.into_inner();
823    Recorded {
824        stream,
825        seq: field(&event, "seq").as_u64().unwrap_or(0),
826        position,
827        epoch_ms: field(&event, "epoch_ms").as_i64().unwrap_or(0),
828        kind: field(&event, "kind")
829            .as_str()
830            .unwrap_or_default()
831            .to_string(),
832        meta: Value(field(&event, "meta")),
833        data: Value(field(&event, "data")),
834    }
835}
836
837fn field(event: &Map<String, Json>, key: &str) -> Json {
838    event.get(key).cloned().unwrap_or(Json::Null)
839}