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}