Skip to main content

cardbox/store/
projection.rs

1//! The read models: the seven card kinds folded into tables in the same file as the log.
2//!
3//! # Why the fold is here and not in Teal
4//!
5//! eventsdb's projection contract is that applying an event and moving the consumer's
6//! cursor happen inside **one** transaction — which is only expressible if the fold writes
7//! through the transaction the log is being read on. That transaction is a Rust value with
8//! a lifetime; there is no way to hand it to Lua and no way to let a Lua error unwind
9//! through it. So the fold is mechanism, and it sits beside the append it mirrors.
10//!
11//! What stays policy is everything above the tables: which of them a query touches, what a
12//! card may be filtered on, how a refusal is worded. `src/cardbox/find.tl` is that half,
13//! and it reaches these tables through the store's read-only SQL hatch.
14//!
15//! # The name is the version
16//!
17//! [`CardsProjection::NAME`] is `cards_v4`, and the suffix is the migration convention
18//! rather than decoration: a projection's name **is** the primary key of its checkpoint,
19//! so a shape change old rows cannot be carried into is done by renaming the projection.
20//! The new name has no checkpoint, so it starts at the beginning of the log and folds all
21//! of it, and nothing has to reason about which of the old rows were still right.
22//! `rebuild()` on the same name is the other route — empty and replay — which is what a
23//! changed fold over unchanged tables wants.
24//!
25//! `cards_v1` was this model without the two alias kinds and without `cb_aliases` /
26//! `cb_alias_log`; `cards_v2` was it without `cards_pruned`, so a database folded by that
27//! build holds rows for cards a journal event has since removed; `cards_v3` was it before
28//! a card carried `params`, a `model` and a run identity in `cb_cards`, a `source` on each
29//! eval, and tags in `cb_tags`. [`crate::Store::open`] carries a database written under
30//! any retired name forward; what it does and what it cannot do is documented there.
31//!
32//! Where this departs from the textbook (Marten's "build the new model beside the old one
33//! and switch when it has caught up"): the two versions here share the `cb_*` table names,
34//! so the old model does not survive the migration. There is nothing to serve it to. This
35//! store is one process that opens the file, folds and answers; a window in which two read
36//! models are both live is a thing a service needs and a library embedded in its only
37//! reader does not.
38//!
39//! # The `cb_` prefix
40//!
41//! Every table here is `cb_`-prefixed because it shares a file with eventsdb's own —
42//! `events`, `stream_seq`, `checkpoints`, `retention`, `exports`. Those names are reserved:
43//! the transaction handed to `apply` refuses writes to them (and refuses creating anything
44//! that would shadow one), so a collision is not a silent overwrite. The prefix is what
45//! keeps the refusal from ever being the thing that tells us.
46
47use eventsdb::sqlite::Projection;
48use eventsdb::sqlite::rusqlite::{self, OptionalExtension, Transaction};
49use eventsdb::{Error, Result};
50use serde_json::Value as Json;
51
52/// The stream prefix a card's events live under: `card-<id>`.
53pub const STREAM_PREFIX: &str = "card-";
54
55/// The stream prefix an alias's events live under: `alias-<name>`.
56pub const ALIAS_PREFIX: &str = "alias-";
57
58/// The one stream the prune journal lives on.
59///
60/// Not a prefix and not per card: it is the log of every removal there has been, in order,
61/// and it is the one stream retention never takes — which is what makes it the pointer
62/// event the removed history leaves behind. What went, when, why, and where the export
63/// that vouched for it is.
64pub const PRUNE_STREAM: &str = "prune";
65
66/// The read model over a card's seven kinds and an alias's two.
67///
68/// Stateless apart from the name it answers to: everything it knows is in the tables,
69/// which is what makes a rebuild a replay rather than a reconstruction of anything held
70/// here.
71pub struct CardsProjection {
72    name: String,
73}
74
75impl Default for CardsProjection {
76    fn default() -> Self {
77        CardsProjection::new()
78    }
79}
80
81impl CardsProjection {
82    /// The consumer name, and so the identity of the cursor. See the module doc for what
83    /// the `_v4` is for.
84    pub const NAME: &'static str = "cards_v4";
85
86    /// The projection this build folds under.
87    pub fn new() -> CardsProjection {
88        CardsProjection {
89            name: CardsProjection::NAME.to_string(),
90        }
91    }
92
93    /// The same fold under some other cursor.
94    ///
95    /// Only the migration test, and only to write a database whose checkpoint is under a
96    /// name this build has retired — which is the one thing about an older store that
97    /// `Store::open` has to handle and that nothing else can produce in-process. The
98    /// tables it creates are this version's, so what the test reproduces is the *cursor*
99    /// of the old build and not its schema; the rest of the old schema is a subset of
100    /// this one, so `init` on the way in would have added the difference anyway.
101    #[cfg(test)]
102    pub fn under(name: &str) -> CardsProjection {
103        CardsProjection {
104            name: name.to_string(),
105        }
106    }
107
108    /// The kinds these streams carry. Naming them is not only a filter: it is what lets
109    /// the runner read through the `(kind, position)` index instead of walking the whole
110    /// log, and it is the reason `apply` may treat an unknown kind as a bug.
111    pub const KINDS: [&'static str; 10] = [
112        "card_opened",
113        "samples_appended",
114        "eval_recorded",
115        "checkpoint_saved",
116        "card_closed",
117        "tag_set",
118        "tag_unset",
119        "alias_bound",
120        "alias_released",
121        "cards_pruned",
122    ];
123}
124
125/// The tables, and the indexes the three questions `find` actually asks need: which cards
126/// are in this pkg, which are in this state, which are the newest, and who descends from
127/// this one.
128///
129/// `cb_aliases` is the *current* binding, one row per name, and `cb_alias_log` is every
130/// binding there has been. The first is a fold that forgets — a rebind overwrites the row,
131/// a release deletes it — and the second is the fold that does not, which is what makes
132/// "what did this alias point at in March" a question with an answer. Neither is the
133/// authority: `alias-<name>` in the log is, and both of these are replayed out of it.
134///
135/// `cb_cards` carries `stats` and `cost` twice — once as the JSON that was written, once
136/// flattened into columns. The JSON is what `get` hands back unchanged, so a card reads the
137/// same whatever a run chose to put in there; the columns are what a `WHERE mean_score >
138/// 0.5` compares without `json_extract` on every row. A key the writer left out is NULL,
139/// and NULL compares false, which is the answer a filter on a card that never recorded a
140/// score should give.
141///
142/// `params_json` is the same arrangement for what a run was *given*: the JSON as the open
143/// wrote it, for `get`, and beside it the four scalars the open put in its `meta` —
144/// `model`, `trace_id`, `work_url`, `fingerprint` — as columns, because those are what a
145/// reader asks by ("every card of this model", "the run that trace belongs to") and what a
146/// fingerprint is for is being compared. Anything else inside `params` is reached with
147/// `json_extract`, which is what `find`'s `params.<path>` clauses expand to; a key that
148/// turns out to be asked for on every query is promoted to a column the way these four
149/// were, under a new projection name.
150///
151/// `cb_tags` is the one table here keyed by something a writer chose: a tag is a label a
152/// person or a schedule puts on a card after the fact, and the set of keys is nobody's to
153/// declare in advance. The current value is one row per `(card, key)` that `tag_set`
154/// overwrites and `tag_unset` deletes; the history is the card's stream.
155const CREATE: &str = "\
156CREATE TABLE IF NOT EXISTS cb_cards (
157    id               TEXT PRIMARY KEY,
158    pkg              TEXT,
159    scenario         TEXT,
160    source           TEXT,
161    created_by       TEXT,
162    note             TEXT,
163    model            TEXT,
164    trace_id         TEXT,
165    work_url         TEXT,
166    fingerprint      TEXT,
167    params_json      TEXT,
168    state            TEXT NOT NULL,
169    opened_ms        INTEGER,
170    closed_ms        INTEGER,
171    opened_position  INTEGER,
172    error            TEXT,
173    stats_json       TEXT,
174    cost_json        TEXT,
175    mean_score       REAL,
176    n                INTEGER,
177    pass_rate        REAL,
178    passed           INTEGER,
179    elapsed_ms       INTEGER,
180    llm_calls        INTEGER,
181    sample_batches   INTEGER NOT NULL DEFAULT 0,
182    sample_rows      INTEGER NOT NULL DEFAULT 0,
183    eval_count       INTEGER NOT NULL DEFAULT 0,
184    checkpoint_count INTEGER NOT NULL DEFAULT 0
185);
186CREATE INDEX IF NOT EXISTS cb_cards_pkg       ON cb_cards (pkg);
187CREATE INDEX IF NOT EXISTS cb_cards_state     ON cb_cards (state);
188CREATE INDEX IF NOT EXISTS cb_cards_opened_ms ON cb_cards (opened_ms);
189CREATE INDEX IF NOT EXISTS cb_cards_model     ON cb_cards (model);
190CREATE INDEX IF NOT EXISTS cb_cards_trace     ON cb_cards (trace_id);
191CREATE INDEX IF NOT EXISTS cb_cards_print     ON cb_cards (fingerprint);
192
193CREATE TABLE IF NOT EXISTS cb_samples (
194    card_id   TEXT    NOT NULL,
195    seq       INTEGER NOT NULL,
196    n         INTEGER,
197    rows_json TEXT,
198    blob      TEXT,
199    size      INTEGER,
200    epoch_ms  INTEGER,
201    PRIMARY KEY (card_id, seq)
202);
203
204CREATE TABLE IF NOT EXISTS cb_evals (
205    card_id   TEXT    NOT NULL,
206    seq       INTEGER NOT NULL,
207    source    TEXT,
208    data_json TEXT,
209    epoch_ms  INTEGER,
210    PRIMARY KEY (card_id, seq)
211);
212
213CREATE TABLE IF NOT EXISTS cb_tags (
214    card_id  TEXT NOT NULL,
215    key      TEXT NOT NULL,
216    value    TEXT NOT NULL,
217    set_ms   INTEGER,
218    PRIMARY KEY (card_id, key)
219);
220CREATE INDEX IF NOT EXISTS cb_tags_key ON cb_tags (key, value);
221
222CREATE TABLE IF NOT EXISTS cb_checkpoints (
223    card_id  TEXT    NOT NULL,
224    seq      INTEGER NOT NULL,
225    blob     TEXT,
226    size     INTEGER,
227    format   TEXT,
228    note     TEXT,
229    epoch_ms INTEGER,
230    PRIMARY KEY (card_id, seq)
231);
232
233CREATE TABLE IF NOT EXISTS cb_lineage (
234    child  TEXT NOT NULL,
235    parent TEXT NOT NULL,
236    PRIMARY KEY (child, parent)
237);
238CREATE INDEX IF NOT EXISTS cb_lineage_parent ON cb_lineage (parent);
239
240CREATE TABLE IF NOT EXISTS cb_blobs (
241    hash TEXT PRIMARY KEY,
242    size INTEGER,
243    refs INTEGER NOT NULL DEFAULT 0
244);
245
246CREATE TABLE IF NOT EXISTS cb_aliases (
247    name     TEXT PRIMARY KEY,
248    card_id  TEXT NOT NULL,
249    pkg      TEXT,
250    bound_ms INTEGER,
251    note     TEXT
252);
253CREATE INDEX IF NOT EXISTS cb_aliases_card ON cb_aliases (card_id);
254
255CREATE TABLE IF NOT EXISTS cb_alias_log (
256    name     TEXT    NOT NULL,
257    seq      INTEGER NOT NULL,
258    kind     TEXT    NOT NULL,
259    card_id  TEXT,
260    epoch_ms INTEGER,
261    note     TEXT,
262    PRIMARY KEY (name, seq)
263);
264";
265
266/// What `reset` undoes. Dropping a table takes its indexes with it, so they are not listed.
267const DROP: &str = "\
268DROP TABLE IF EXISTS cb_cards;
269DROP TABLE IF EXISTS cb_samples;
270DROP TABLE IF EXISTS cb_evals;
271DROP TABLE IF EXISTS cb_tags;
272DROP TABLE IF EXISTS cb_checkpoints;
273DROP TABLE IF EXISTS cb_lineage;
274DROP TABLE IF EXISTS cb_blobs;
275DROP TABLE IF EXISTS cb_aliases;
276DROP TABLE IF EXISTS cb_alias_log;
277";
278
279impl Projection for CardsProjection {
280    fn name(&self) -> &str {
281        &self.name
282    }
283
284    fn kinds(&self) -> Option<Vec<String>> {
285        Some(
286            CardsProjection::KINDS
287                .iter()
288                .map(|k| k.to_string())
289                .collect(),
290        )
291    }
292
293    /// Create the tables — after dropping them, when the ones there predate this shape.
294    ///
295    /// `CREATE TABLE IF NOT EXISTS` leaves an existing table as it is, so a `cb_cards`
296    /// written by an older build has no `params_json`, and the `CREATE INDEX` on `model`
297    /// that follows would fail on every open — which is what `cardbox version` did on a
298    /// store a `cards_v3` build had created. The tables are a fold of the log and nothing
299    /// else, so an outdated one is dropped and folded again; [`crate::Store::open`] runs
300    /// the rebuild, having asked [`shape_outdated`] the same question before this ran.
301    fn init(&mut self, tx: &Transaction<'_>) -> Result<()> {
302        if shape_outdated(tx)? {
303            tx.execute_batch(DROP).map_err(storage)?;
304        }
305        tx.execute_batch(CREATE).map_err(storage)
306    }
307
308    fn reset(&mut self, tx: &Transaction<'_>) -> Result<()> {
309        tx.execute_batch(DROP).map_err(storage)
310    }
311
312    /// One event, folded.
313    ///
314    /// The card's id comes from the **stream name** rather than from `meta`: the stream is
315    /// what the store's own decisions fold over, so it is the identity the invariants are
316    /// already stated in terms of, and a `meta` that disagreed with it would describe a
317    /// card that no `append_if` was ever protecting. An event of one of these kinds on a
318    /// stream that is not a card's is a bug in whatever wrote it, and is reported rather
319    /// than skipped.
320    ///
321    /// An unknown kind is likewise an error. `kinds()` is what the runner filters on, so
322    /// one arriving here means the filter and this match have drifted apart, and a fold
323    /// that quietly ignored it would leave a read model missing rows with nothing saying so.
324    /// **Yes — and only because every removal announces itself first.**
325    ///
326    /// The default is `false`, and for an accumulating model the default is the right
327    /// answer: `sample_rows`, `eval_count` and `cb_blobs.refs` are running totals, and a
328    /// replay over a log missing part of its input produces a smaller number with nothing
329    /// about it saying so. What makes this model different is the shape of the only
330    /// removal it allows.
331    ///
332    /// A prune is `retain(Plan::Streams, ..)` over whole `card-<id>` streams, and a
333    /// `cards_pruned` event on [`PRUNE_STREAM`] is appended **before** it — that stream is
334    /// never itself retained, so the journal survives what it describes. On a rebuild the
335    /// pruned cards' events are gone, so no row is ever created for them and no counter
336    /// ever incremented; the journal event then replays over an absent card and
337    /// [`purge`] returns without touching anything. The totals come out the same as they
338    /// were, because the events that would have moved them and the event that moved them
339    /// back are both absent.
340    ///
341    /// That is the whole of the claim, and it is narrow on purpose: it holds for a
342    /// removal of whole card streams that a journal event announced, and it would not
343    /// hold for a bare [`eventsdb::sqlite::Plan::Before`] or `OlderThan` over this log.
344    /// Neither is reachable — [`crate::Store`] exposes `retain_streams` and nothing else.
345    fn tolerates_truncation(&self) -> bool {
346        true
347    }
348
349    fn apply(&mut self, tx: &Transaction<'_>, event: &eventsdb::Recorded) -> Result<()> {
350        let kind = event.kind();
351        let seq = event.seq() as i64;
352        let position = event.position.get() as i64;
353        let epoch_ms = num(event.event.get("epoch_ms")).unwrap_or(0);
354        let meta = event.event.get("meta");
355        let data = event.event.get("data");
356
357        // Two stream shapes, so the kind picks the prefix before anything is stripped: an
358        // `alias_bound` is on `alias-<name>` and a `card_opened` on `card-<id>`, and
359        // asking either name to yield the other's identity is how a fold would quietly
360        // file an alias under a card.
361        match kind {
362            "card_opened" => opened(
363                tx,
364                card_id(&event.stream, kind)?,
365                epoch_ms,
366                position,
367                meta,
368                data,
369            ),
370            "samples_appended" => samples(tx, card_id(&event.stream, kind)?, seq, epoch_ms, data),
371            "eval_recorded" => eval(tx, card_id(&event.stream, kind)?, seq, epoch_ms, meta, data),
372            "tag_set" => tag_set(tx, card_id(&event.stream, kind)?, epoch_ms, meta),
373            "tag_unset" => tag_unset(tx, card_id(&event.stream, kind)?, meta),
374            "checkpoint_saved" => {
375                checkpoint(tx, card_id(&event.stream, kind)?, seq, epoch_ms, data)
376            }
377            "card_closed" => closed(tx, card_id(&event.stream, kind)?, epoch_ms, meta, data),
378            "alias_bound" => bound(
379                tx,
380                alias_name(&event.stream, kind)?,
381                seq,
382                epoch_ms,
383                meta,
384                data,
385            ),
386            "alias_released" => released(
387                tx,
388                alias_name(&event.stream, kind)?,
389                seq,
390                epoch_ms,
391                meta,
392                data,
393            ),
394            "cards_pruned" => {
395                on_prune_stream(&event.stream, kind)?;
396                pruned(tx, data)
397            }
398            other => Err(Error::storage(format!(
399                "the {} projection was handed a {other:?} event, which is not one of the \
400                 kinds it asked for ({})",
401                self.name,
402                CardsProjection::KINDS.join(", ")
403            ))),
404        }
405    }
406}
407
408/// One column per table that a build before this shape did not have. A table that
409/// exists without it was written by that build.
410///
411/// `cb_cards.params_json` arrived with `cards_v4`, and so did `cb_evals.source`; a table
412/// added whole (`cb_tags`) needs no entry, `CREATE TABLE IF NOT EXISTS` adds it.
413pub const SHAPE_MARKS: [(&str, &str); 2] = [("cb_cards", "params_json"), ("cb_evals", "source")];
414
415/// The SQL that asks whether `table` carries `column`, for the hatch and for `init`.
416pub fn shape_probe(table: &str) -> String {
417    format!("PRAGMA table_info({table})")
418}
419
420/// Whether the `cb_*` tables in this database predate [`SHAPE_MARKS`].
421///
422/// Read inside the transaction `init` runs in, so the drop that follows a `true` and the
423/// create after it are one change.
424pub fn shape_outdated(tx: &Transaction<'_>) -> Result<bool> {
425    for (table, column) in SHAPE_MARKS {
426        let mut stmt = tx.prepare(&shape_probe(table)).map_err(storage)?;
427        let names = stmt
428            .query_map([], |row| row.get::<_, String>(1))
429            .map_err(storage)?
430            .collect::<rusqlite::Result<Vec<String>>>()
431            .map_err(storage)?;
432        if !names.is_empty() && !names.iter().any(|n| n == column) {
433            return Ok(true);
434        }
435    }
436    Ok(false)
437}
438
439/// `card-<id>` → `<id>`.
440fn card_id<'a>(stream: &'a str, kind: &str) -> Result<&'a str> {
441    stream.strip_prefix(STREAM_PREFIX).ok_or_else(|| {
442        Error::storage(format!(
443            "a {kind:?} event is on stream {stream:?}, which is not a card's: \
444             a card's stream is {STREAM_PREFIX}<id>"
445        ))
446    })
447}
448
449/// `alias-<name>` → `<name>`.
450///
451/// The alias's name comes from the stream for the same reason a card's id does: the
452/// stream is what the store's decision folds over, so it is the identity the invariant is
453/// already stated in terms of. A `meta.name` that disagreed with it would name a
454/// reservation nobody was holding.
455fn alias_name<'a>(stream: &'a str, kind: &str) -> Result<&'a str> {
456    stream.strip_prefix(ALIAS_PREFIX).ok_or_else(|| {
457        Error::storage(format!(
458            "a {kind:?} event is on stream {stream:?}, which is not an alias's: \
459             an alias's stream is {ALIAS_PREFIX}<name>"
460        ))
461    })
462}
463
464/// The row a card starts as.
465///
466/// `ON CONFLICT DO NOTHING`: a stream carries one `card_opened` because `append_if`'s
467/// `unwritten` decision is what `cards.open` writes under, and only a raw `store:append`
468/// could produce a second. If one is there anyway, the first open stays the card's opening
469/// — the alternative is an error that would refuse every later read of the whole model,
470/// including the rebuild that would be the way out of it.
471fn opened(
472    tx: &Transaction<'_>,
473    id: &str,
474    epoch_ms: i64,
475    position: i64,
476    meta: Option<&Json>,
477    data: Option<&Json>,
478) -> Result<()> {
479    let params = data.and_then(|d| d.get("params"));
480    tx.execute(
481        "INSERT INTO cb_cards (id, pkg, scenario, source, created_by, note,
482                               model, trace_id, work_url, fingerprint, params_json,
483                               state, opened_ms, opened_position)
484         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'open', ?12, ?13)
485         ON CONFLICT (id) DO NOTHING",
486        rusqlite::params![
487            id,
488            text(meta, "pkg"),
489            text(meta, "scenario"),
490            text(meta, "source"),
491            text(meta, "created_by"),
492            text(data, "note"),
493            text(meta, "model"),
494            text(meta, "trace_id"),
495            text(meta, "work_url"),
496            text(meta, "fingerprint"),
497            params.map(Json::to_string),
498            epoch_ms,
499            position,
500        ],
501    )
502    .map_err(storage)?;
503
504    // `data.parents` is an array when a run named one and an empty *object* when it named
505    // none — Lua cannot tell `{}` from `[]`, and the host resolves that in the one
506    // direction that round-trips a cleared record. `as_array` answering `None` for the
507    // object is therefore the same answer as an empty list, which is what this wants.
508    let parents = data.and_then(|d| d.get("parents")).and_then(Json::as_array);
509    for parent in parents.into_iter().flatten() {
510        let Some(parent) = parent.as_str() else {
511            continue;
512        };
513        tx.execute(
514            "INSERT INTO cb_lineage (child, parent) VALUES (?1, ?2)
515             ON CONFLICT (child, parent) DO NOTHING",
516            rusqlite::params![id, parent],
517        )
518        .map_err(storage)?;
519    }
520    Ok(())
521}
522
523fn samples(
524    tx: &Transaction<'_>,
525    id: &str,
526    seq: i64,
527    epoch_ms: i64,
528    data: Option<&Json>,
529) -> Result<()> {
530    let n = num(data.and_then(|d| d.get("n")));
531    let rows = data
532        .and_then(|d| d.get("rows"))
533        .map(|rows| rows.to_string());
534    let blob = text(data, "blob");
535    let size = num(data.and_then(|d| d.get("size")));
536    tx.execute(
537        "INSERT INTO cb_samples (card_id, seq, n, rows_json, blob, size, epoch_ms)
538         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
539         ON CONFLICT (card_id, seq) DO NOTHING",
540        rusqlite::params![id, seq, n, rows, blob.as_deref(), size, epoch_ms],
541    )
542    .map_err(storage)?;
543    tx.execute(
544        "UPDATE cb_cards SET sample_batches = sample_batches + 1,
545                             sample_rows = sample_rows + ?2
546         WHERE id = ?1",
547        rusqlite::params![id, n.unwrap_or(0)],
548    )
549    .map_err(storage)?;
550    if let Some(hash) = blob {
551        reference_blob(tx, &hash, size)?;
552    }
553    Ok(())
554}
555
556/// One assessment of a card. `meta.source` says who made it — `code`, `llm_judge` or
557/// `human` — and is a column because "every human verdict on this pkg" is a question.
558fn eval(
559    tx: &Transaction<'_>,
560    id: &str,
561    seq: i64,
562    epoch_ms: i64,
563    meta: Option<&Json>,
564    data: Option<&Json>,
565) -> Result<()> {
566    tx.execute(
567        "INSERT INTO cb_evals (card_id, seq, source, data_json, epoch_ms)
568         VALUES (?1, ?2, ?3, ?4, ?5)
569         ON CONFLICT (card_id, seq) DO NOTHING",
570        rusqlite::params![
571            id,
572            seq,
573            text(meta, "source"),
574            data.map(Json::to_string),
575            epoch_ms
576        ],
577    )
578    .map_err(storage)?;
579    tx.execute(
580        "UPDATE cb_cards SET eval_count = eval_count + 1 WHERE id = ?1",
581        rusqlite::params![id],
582    )
583    .map_err(storage)?;
584    Ok(())
585}
586
587fn checkpoint(
588    tx: &Transaction<'_>,
589    id: &str,
590    seq: i64,
591    epoch_ms: i64,
592    data: Option<&Json>,
593) -> Result<()> {
594    let blob = text(data, "blob");
595    let size = num(data.and_then(|d| d.get("size")));
596    tx.execute(
597        "INSERT INTO cb_checkpoints (card_id, seq, blob, size, format, note, epoch_ms)
598         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
599         ON CONFLICT (card_id, seq) DO NOTHING",
600        rusqlite::params![
601            id,
602            seq,
603            blob.as_deref(),
604            size,
605            text(data, "format"),
606            text(data, "note"),
607            epoch_ms,
608        ],
609    )
610    .map_err(storage)?;
611    tx.execute(
612        "UPDATE cb_cards SET checkpoint_count = checkpoint_count + 1 WHERE id = ?1",
613        rusqlite::params![id],
614    )
615    .map_err(storage)?;
616    if let Some(hash) = blob {
617        reference_blob(tx, &hash, size)?;
618    }
619    Ok(())
620}
621
622/// A label on a card. Last write wins: the row is the current value, and what it replaced
623/// is on the stream. A `tag_set` without a key or a value is a write that went around
624/// `cards.tag`, and is reported rather than filed as a row with nothing in it.
625fn tag_set(tx: &Transaction<'_>, id: &str, epoch_ms: i64, meta: Option<&Json>) -> Result<()> {
626    let (Some(key), Some(value)) = (text(meta, "key"), text(meta, "value")) else {
627        return Err(Error::storage(format!(
628            "a tag_set on {STREAM_PREFIX}{id} carries no meta.key and meta.value"
629        )));
630    };
631    tx.execute(
632        "INSERT INTO cb_tags (card_id, key, value, set_ms) VALUES (?1, ?2, ?3, ?4)
633         ON CONFLICT (card_id, key) DO UPDATE SET value = excluded.value,
634                                                  set_ms = excluded.set_ms",
635        rusqlite::params![id, key, value, epoch_ms],
636    )
637    .map_err(storage)?;
638    Ok(())
639}
640
641/// The label is gone. Unsetting a key that was never set is nothing to do, not an error:
642/// the event says the card does not carry the key, and it does not.
643fn tag_unset(tx: &Transaction<'_>, id: &str, meta: Option<&Json>) -> Result<()> {
644    let Some(key) = text(meta, "key") else {
645        return Err(Error::storage(format!(
646            "a tag_unset on {STREAM_PREFIX}{id} carries no meta.key"
647        )));
648    };
649    tx.execute(
650        "DELETE FROM cb_tags WHERE card_id = ?1 AND key = ?2",
651        rusqlite::params![id, key],
652    )
653    .map_err(storage)?;
654    Ok(())
655}
656
657/// How a card ends: the state, the JSON as written, and the handful of numbers flattened
658/// out of it so a filter can compare them.
659fn closed(
660    tx: &Transaction<'_>,
661    id: &str,
662    epoch_ms: i64,
663    meta: Option<&Json>,
664    data: Option<&Json>,
665) -> Result<()> {
666    let state = match text(meta, "outcome").as_deref() {
667        Some("ok") => "closed_ok",
668        _ => "closed_failed",
669    };
670    let stats = data.and_then(|d| d.get("stats"));
671    let cost = data.and_then(|d| d.get("cost"));
672    tx.execute(
673        "UPDATE cb_cards SET state = ?2, closed_ms = ?3, error = ?4,
674                             stats_json = ?5, cost_json = ?6,
675                             mean_score = ?7, n = ?8, pass_rate = ?9, passed = ?10,
676                             elapsed_ms = ?11, llm_calls = ?12
677         WHERE id = ?1",
678        rusqlite::params![
679            id,
680            state,
681            epoch_ms,
682            text(data, "error"),
683            stats.map(Json::to_string),
684            cost.map(Json::to_string),
685            real(stats.and_then(|s| s.get("mean_score"))),
686            num(stats.and_then(|s| s.get("n"))),
687            real(stats.and_then(|s| s.get("pass_rate"))),
688            num(stats.and_then(|s| s.get("passed"))),
689            num(cost.and_then(|c| c.get("elapsed_ms"))),
690            num(cost.and_then(|c| c.get("llm_calls"))),
691        ],
692    )
693    .map_err(storage)?;
694    Ok(())
695}
696
697/// An alias now points here.
698///
699/// The current binding is one row that the rebind overwrites, because "what does this
700/// name mean" has one answer and a table with two rows for it would need a reader to know
701/// which. What the overwrite would lose goes to `cb_alias_log` first, which keeps every
702/// one of them.
703fn bound(
704    tx: &Transaction<'_>,
705    name: &str,
706    seq: i64,
707    epoch_ms: i64,
708    meta: Option<&Json>,
709    data: Option<&Json>,
710) -> Result<()> {
711    let card_id = text(meta, "card_id");
712    let note = text(data, "note");
713    alias_logged(
714        tx,
715        name,
716        seq,
717        "alias_bound",
718        card_id.as_deref(),
719        epoch_ms,
720        note.as_deref(),
721    )?;
722    // An `alias_bound` with no `card_id` is a binding to nothing, which the store cannot
723    // write: `bind_alias` reads the card before the append and puts its id in the meta.
724    // One arriving here anyway is reported rather than filed as a row pointing nowhere.
725    let Some(card_id) = card_id else {
726        return Err(Error::storage(format!(
727            "an alias_bound on {ALIAS_PREFIX}{name} carries no meta.card_id, \
728             so it binds the name to nothing"
729        )));
730    };
731    tx.execute(
732        "INSERT INTO cb_aliases (name, card_id, pkg, bound_ms, note) VALUES (?1, ?2, ?3, ?4, ?5)
733         ON CONFLICT (name) DO UPDATE SET card_id = excluded.card_id, pkg = excluded.pkg,
734                                          bound_ms = excluded.bound_ms, note = excluded.note",
735        rusqlite::params![name, card_id, text(meta, "pkg"), epoch_ms, note],
736    )
737    .map_err(storage)?;
738    Ok(())
739}
740
741/// The name points at nothing again. The row goes; the history does not.
742fn released(
743    tx: &Transaction<'_>,
744    name: &str,
745    seq: i64,
746    epoch_ms: i64,
747    meta: Option<&Json>,
748    data: Option<&Json>,
749) -> Result<()> {
750    alias_logged(
751        tx,
752        name,
753        seq,
754        "alias_released",
755        text(meta, "card_id").as_deref(),
756        epoch_ms,
757        text(data, "note").as_deref(),
758    )?;
759    tx.execute(
760        "DELETE FROM cb_aliases WHERE name = ?1",
761        rusqlite::params![name],
762    )
763    .map_err(storage)?;
764    Ok(())
765}
766
767/// One line of an alias's history, keyed by the seq it has on its own stream.
768fn alias_logged(
769    tx: &Transaction<'_>,
770    name: &str,
771    seq: i64,
772    kind: &str,
773    card_id: Option<&str>,
774    epoch_ms: i64,
775    note: Option<&str>,
776) -> Result<()> {
777    tx.execute(
778        "INSERT INTO cb_alias_log (name, seq, kind, card_id, epoch_ms, note)
779         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
780         ON CONFLICT (name, seq) DO NOTHING",
781        rusqlite::params![name, seq, kind, card_id, epoch_ms, note],
782    )
783    .map_err(storage)?;
784    Ok(())
785}
786
787/// The journal event's stream has to be the journal's.
788///
789/// The other two identities in this fold are read *out of* the stream name; this one is
790/// checked against a constant, because a `cards_pruned` is about a set of cards named in
791/// its own data and the stream carries no identity at all. One anywhere else is a write
792/// that went around [`crate::Store::retain_streams`].
793fn on_prune_stream(stream: &str, kind: &str) -> Result<()> {
794    if stream == PRUNE_STREAM {
795        return Ok(());
796    }
797    Err(Error::storage(format!(
798        "a {kind:?} event is on stream {stream:?}: the prune journal is the one stream \
799         {PRUNE_STREAM:?}"
800    )))
801}
802
803/// Cards have gone. Take them out of every table that holds one.
804///
805/// `data.cards` is the list; anything that is not a string in it is skipped rather than
806/// refused, on the same reading `opened` gives a malformed `parents`.
807fn pruned(tx: &Transaction<'_>, data: Option<&Json>) -> Result<()> {
808    let cards = data.and_then(|d| d.get("cards")).and_then(Json::as_array);
809    for card in cards.into_iter().flatten() {
810        if let Some(id) = card.as_str() {
811            purge(tx, id)?;
812        }
813    }
814    Ok(())
815}
816
817/// One card, out of the read models.
818///
819/// **The first line is what makes a rebuild work.** After the retain, a pruned card has no
820/// events, so a replay reaches this event with no row ever having been created — and every
821/// delete below, and every `refs` decrement, would then be a second application of a purge
822/// the first fold already did. So an absent card is nothing to do. Which is also the
823/// honest reading of the event: it says these cards are gone, and one that is not here is.
824///
825/// The alias check is under that gate rather than over it for the same reason. An aliased
826/// card is refused by the policy side (`cards.prune` skips it), so reaching this is a
827/// journal event somebody wrote by hand, and it is a fold error — but only while the card
828/// is still there to be pointed at. Once its events are gone the question no longer
829/// arises, and a rebuild that raised it would be a store that cannot be rebuilt.
830fn purge(tx: &Transaction<'_>, id: &str) -> Result<()> {
831    let present: Option<i64> = tx
832        .query_row(
833            "SELECT 1 FROM cb_cards WHERE id = ?1",
834            rusqlite::params![id],
835            |row| row.get(0),
836        )
837        .optional()
838        .map_err(storage)?;
839    if present.is_none() {
840        return Ok(());
841    }
842
843    let named: Option<String> = tx
844        .query_row(
845            "SELECT name FROM cb_aliases WHERE card_id = ?1 ORDER BY name LIMIT 1",
846            rusqlite::params![id],
847            |row| row.get(0),
848        )
849        .optional()
850        .map_err(storage)?;
851    if let Some(name) = named {
852        return Err(Error::storage(format!(
853            "card {id} was pruned while the alias {name:?} still points at it: a card with \
854             a name is not prunable, and the name would be left pointing at nothing"
855        )));
856    }
857
858    // Row by row, and `UNION ALL`, because `refs` counts references and not distinct
859    // blobs: two sample batches that happened to hold the same bytes incremented it twice.
860    let hashes: Vec<String> = {
861        let mut stmt = tx
862            .prepare(
863                "SELECT blob FROM cb_samples WHERE card_id = ?1 AND blob IS NOT NULL \
864                 UNION ALL \
865                 SELECT blob FROM cb_checkpoints WHERE card_id = ?1 AND blob IS NOT NULL",
866            )
867            .map_err(storage)?;
868        let rows = stmt
869            .query_map(rusqlite::params![id], |row| row.get::<_, String>(0))
870            .map_err(storage)?;
871        rows.collect::<rusqlite::Result<Vec<String>>>()
872            .map_err(storage)?
873    };
874    for hash in hashes {
875        tx.execute(
876            "UPDATE cb_blobs SET refs = refs - 1 WHERE hash = ?1",
877            rusqlite::params![hash],
878        )
879        .map_err(storage)?;
880    }
881
882    // `cb_lineage` both ways. A pruned card that is somebody's parent is refused by the
883    // policy, so the `parent` half normally matches nothing; it is here because an edge
884    // naming a card that no longer exists is exactly the dangling row this whole purge is
885    // for.
886    for sql in [
887        "DELETE FROM cb_samples WHERE card_id = ?1",
888        "DELETE FROM cb_evals WHERE card_id = ?1",
889        "DELETE FROM cb_tags WHERE card_id = ?1",
890        "DELETE FROM cb_checkpoints WHERE card_id = ?1",
891        "DELETE FROM cb_lineage WHERE child = ?1 OR parent = ?1",
892        "DELETE FROM cb_cards WHERE id = ?1",
893    ] {
894        tx.execute(sql, rusqlite::params![id]).map_err(storage)?;
895    }
896    Ok(())
897}
898
899/// One more thing points at these bytes. The blob GC is the reader: a blob whose `refs`
900/// reach 0 after the events naming it are gone is the only one it may remove.
901fn reference_blob(tx: &Transaction<'_>, hash: &str, size: Option<i64>) -> Result<()> {
902    tx.execute(
903        "INSERT INTO cb_blobs (hash, size, refs) VALUES (?1, ?2, 1)
904         ON CONFLICT (hash) DO UPDATE SET refs = refs + 1, size = COALESCE(excluded.size, size)",
905        rusqlite::params![hash, size],
906    )
907    .map_err(storage)?;
908    Ok(())
909}
910
911fn text(object: Option<&Json>, key: &str) -> Option<String> {
912    object
913        .and_then(|o| o.get(key))
914        .and_then(Json::as_str)
915        .map(str::to_string)
916}
917
918fn num(value: Option<&Json>) -> Option<i64> {
919    value.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
920}
921
922fn real(value: Option<&Json>) -> Option<f64> {
923    value.and_then(Json::as_f64)
924}
925
926fn storage(e: rusqlite::Error) -> Error {
927    Error::storage(e.to_string())
928}