beck_rt/
log.rs

1//! The log engine — "the log is the database" (§5.3).
2//!
3//! Phase 0 wrote this against one hand-written `Event` type. Phase 1 writes it against
4//! [`beck_core::Value`], because the event type now comes from the *program*: `union Event` in the
5//! source is what decides what a log record contains, and the runtime must not know its shape.
6//!
7//! The contract every substrate keeps is unchanged, and is the thing all the determinism rests on:
8//!
9//! * `seq` is assigned **here and nowhere else**, densely, from a single writer (§3.7).
10//! * A batch of events from one command is appended **atomically at contiguous `seq`s** — no fold
11//!   ever observes half a command's consequences.
12//! * Reads are ordered and replayable from any position; that is what `beck replay`,
13//!   `(subscription, seq)` resumption and forked worlds are made of.
14//!
15//! We do not write a storage engine (`docs/01-vision-and-premise.md` §1.5). This is a small log
16//! engine on top of proven storage: redb for rung 0, PostgreSQL for everything above it.
17
18use std::sync::Mutex;
19
20use anyhow::{bail, Context, Result};
21use async_trait::async_trait;
22use redb::ReadableTable;
23use rusqlite::OptionalExtension;
24
25/// The records a log is made of, defined in [`beck_host::record`] because a browser tab holds a log
26/// too and an envelope has to mean the same thing in both ([`docs/17`](../../../../../docs/17-playground.md)
27/// §17.2). What is *here* is the engine: the substrates, and the contract above.
28pub use beck_host::record::{Envelope, Instant, Pending, Seq, Snapshot};
29
30#[async_trait]
31pub trait LogStore: Send + Sync + 'static {
32    /// Which substrate this is — reported by the CLI and the harnesses, because a number without
33    /// its substrate is meaningless.
34    fn kind(&self) -> &'static str;
35    async fn head(&self) -> Result<Seq>;
36    async fn floor(&self) -> Result<Seq>;
37    /// Append a batch atomically at contiguous `seq`s. Returns the stamped envelopes.
38    async fn append(&self, batch: &[Pending]) -> Result<Vec<Envelope>>;
39    async fn read(&self, after: Seq, limit: usize) -> Result<Vec<Envelope>>;
40    async fn put_snapshot(&self, snapshot: &Snapshot) -> Result<()>;
41    async fn snapshot_at_or_before(&self, seq: Seq) -> Result<Option<Snapshot>>;
42}
43
44// ---------------------------------------------------------------------------------------------
45// In-memory — for tests, and for measuring everything else without a disk in the way.
46// ---------------------------------------------------------------------------------------------
47
48#[derive(Default)]
49pub struct MemoryLog {
50    inner: Mutex<MemoryInner>,
51}
52
53#[derive(Default)]
54struct MemoryInner {
55    events: Vec<Envelope>,
56    snapshots: Vec<Snapshot>,
57}
58
59impl MemoryLog {
60    pub fn new() -> MemoryLog {
61        MemoryLog::default()
62    }
63}
64
65#[async_trait]
66impl LogStore for MemoryLog {
67    fn kind(&self) -> &'static str {
68        "memory"
69    }
70
71    async fn head(&self) -> Result<Seq> {
72        Ok(self
73            .inner
74            .lock()
75            .expect("log mutex")
76            .events
77            .last()
78            .map(|e| e.seq)
79            .unwrap_or(0))
80    }
81
82    async fn floor(&self) -> Result<Seq> {
83        Ok(0)
84    }
85
86    async fn append(&self, batch: &[Pending]) -> Result<Vec<Envelope>> {
87        let mut inner = self.inner.lock().expect("log mutex");
88        let mut next = inner.events.last().map(|e| e.seq).unwrap_or(0);
89        let mut out = Vec::with_capacity(batch.len());
90        for p in batch {
91            next += 1;
92            out.push(Envelope {
93                seq: next,
94                at: p.at,
95                actor: p.actor.clone(),
96                body: p.body.clone(),
97            });
98        }
99        inner.events.extend(out.iter().cloned());
100        Ok(out)
101    }
102
103    async fn read(&self, after: Seq, limit: usize) -> Result<Vec<Envelope>> {
104        let inner = self.inner.lock().expect("log mutex");
105        Ok(inner
106            .events
107            .iter()
108            .filter(|e| e.seq > after)
109            .take(limit)
110            .cloned()
111            .collect())
112    }
113
114    async fn put_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
115        self.inner
116            .lock()
117            .expect("log mutex")
118            .snapshots
119            .push(snapshot.clone());
120        Ok(())
121    }
122
123    async fn snapshot_at_or_before(&self, seq: Seq) -> Result<Option<Snapshot>> {
124        let inner = self.inner.lock().expect("log mutex");
125        Ok(inner
126            .snapshots
127            .iter()
128            .filter(|s| s.seq <= seq)
129            .max_by_key(|s| s.seq)
130            .cloned())
131    }
132}
133
134// ---------------------------------------------------------------------------------------------
135// redb — rung 0. "`beck run` needs no server; the log file is still replayable" (§5.3).
136// ---------------------------------------------------------------------------------------------
137
138const EVENTS: redb::TableDefinition<u64, &[u8]> = redb::TableDefinition::new("events");
139const SNAPSHOTS: redb::TableDefinition<u64, &[u8]> = redb::TableDefinition::new("snapshots");
140/// The encoding the two tables above are written in — see [`beck_core::repr::FORMAT`].
141const META: redb::TableDefinition<&str, u32> = redb::TableDefinition::new("meta");
142
143pub struct RedbLog {
144    db: redb::Database,
145}
146
147impl RedbLog {
148    pub fn open(path: &std::path::Path) -> Result<RedbLog> {
149        let db = redb::Database::create(path)
150            .with_context(|| format!("opening the log at {}", path.display()))?;
151        // Create the tables eagerly so a read on a fresh database does not fail.
152        let tx = db.begin_write()?;
153        {
154            let events = tx.open_table(EVENTS)?;
155            let empty = events.first()?.is_none();
156            let _ = tx.open_table(SNAPSHOTS)?;
157            let mut meta = tx.open_table(META)?;
158            // Stamp a fresh store; refuse one written in another encoding. Reading old bytes with
159            // a new decoder does not fail — postcard would happily interpret JSON text as some
160            // `Repr` — and a log that decodes to something other than what was written is the one
161            // outcome an append-only history may not have.
162            let stamped = meta.get("format")?.map(|v| v.value());
163            match stamped {
164                Some(found) if found != beck_core::repr::FORMAT => bail!(
165                    "the log at {} was written in format {found} and this build reads format {}. \
166                     Replay it through the older build and export, or start a fresh log — reading \
167                     it as-is would decode to something that is not what was written",
168                    path.display(),
169                    beck_core::repr::FORMAT
170                ),
171                Some(_) => {}
172                // No stamp and no events is a store this build created; no stamp *with* events is
173                // a Phase 2 log, which is format 1.
174                None if empty => {
175                    meta.insert("format", beck_core::repr::FORMAT)?;
176                }
177                None => bail!(
178                    "the log at {} carries no format stamp, so it was written by a build before \
179                     format {} — its events are JSON text and this build reads postcard",
180                    path.display(),
181                    beck_core::repr::FORMAT
182                ),
183            }
184        }
185        tx.commit()?;
186        Ok(RedbLog { db })
187    }
188}
189
190#[async_trait]
191impl LogStore for RedbLog {
192    fn kind(&self) -> &'static str {
193        "redb"
194    }
195
196    async fn head(&self) -> Result<Seq> {
197        let tx = self.db.begin_read()?;
198        let table = tx.open_table(EVENTS)?;
199        let head = table.last()?.map(|(k, _)| k.value()).unwrap_or(0);
200        Ok(head)
201    }
202
203    async fn floor(&self) -> Result<Seq> {
204        let tx = self.db.begin_read()?;
205        let table = tx.open_table(EVENTS)?;
206        let floor = table
207            .first()?
208            .map(|(k, _)| k.value().saturating_sub(1))
209            .unwrap_or(0);
210        Ok(floor)
211    }
212
213    async fn append(&self, batch: &[Pending]) -> Result<Vec<Envelope>> {
214        let tx = self.db.begin_write()?;
215        let mut out = Vec::with_capacity(batch.len());
216        {
217            let mut table = tx.open_table(EVENTS)?;
218            let mut next = { table.last()?.map(|(k, _)| k.value()).unwrap_or(0) };
219            for p in batch {
220                next += 1;
221                let env = Envelope {
222                    seq: next,
223                    at: p.at,
224                    actor: p.actor.clone(),
225                    body: p.body.clone(),
226                };
227                table.insert(next, env.encode()?.as_slice())?;
228                out.push(env);
229            }
230        }
231        // One commit for the whole batch: group commit is the difference between per-command fsync
232        // and a respectable events/s number, and it is what makes the batch atomic.
233        tx.commit()?;
234        Ok(out)
235    }
236
237    async fn read(&self, after: Seq, limit: usize) -> Result<Vec<Envelope>> {
238        let tx = self.db.begin_read()?;
239        let table = tx.open_table(EVENTS)?;
240        let mut out = Vec::new();
241        for entry in table.range((after + 1)..)? {
242            let (_, v) = entry?;
243            out.push(Envelope::decode(v.value())?);
244            if out.len() >= limit {
245                break;
246            }
247        }
248        Ok(out)
249    }
250
251    async fn put_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
252        let tx = self.db.begin_write()?;
253        {
254            let mut table = tx.open_table(SNAPSHOTS)?;
255            let bytes = beck_core::repr::to_bytes(&snapshot.state)?;
256            table.insert(snapshot.seq, bytes.as_slice())?;
257        }
258        tx.commit()?;
259        Ok(())
260    }
261
262    async fn snapshot_at_or_before(&self, seq: Seq) -> Result<Option<Snapshot>> {
263        let tx = self.db.begin_read()?;
264        let table = tx.open_table(SNAPSHOTS)?;
265        let mut best: Option<Snapshot> = None;
266        for entry in table.range(..=seq)? {
267            let (k, v) = entry?;
268            let state = beck_core::repr::from_bytes(v.value()).context("decoding a snapshot")?;
269            best = Some(Snapshot {
270                seq: k.value(),
271                state,
272            });
273        }
274        Ok(best)
275    }
276}
277
278// ---------------------------------------------------------------------------------------------
279// PostgreSQL — the durable substrate above rung 0 (§5.3).
280// ---------------------------------------------------------------------------------------------
281
282/// The DDL the compiler emits for a `durable` fold (§4.3 stage 4, "emit state artefacts").
283pub const DDL: &str = "\
284CREATE TABLE IF NOT EXISTS beck_log (
285    seq   BIGSERIAL PRIMARY KEY,
286    at    BIGINT      NOT NULL,
287    actor TEXT        NOT NULL,
288    body  BYTEA       NOT NULL
289);
290-- `seq` is append-only and therefore perfectly correlated with physical order, which is the one
291-- case BRIN is built for: a summary per block range instead of a tuple per row. Every read this
292-- store performs is `WHERE seq > $1 ORDER BY seq LIMIT $2`, so the index is scanned as a range and
293-- never probed as a point. The primary key's btree stays because it enforces uniqueness; BRIN is
294-- what the range scans use, and it is kilobytes where the btree is megabytes.
295CREATE INDEX IF NOT EXISTS beck_log_seq_brin ON beck_log USING BRIN (seq);
296CREATE TABLE IF NOT EXISTS beck_snapshot (
297    seq   BIGINT PRIMARY KEY,
298    state BYTEA  NOT NULL
299);
300-- The format the two tables above are written in. Checked on open, because a log read back under a
301-- different encoding does not fail — it produces plausible nonsense, and an append-only audit trail
302-- may not have that outcome (`beck_core::repr::FORMAT`).
303CREATE TABLE IF NOT EXISTS beck_meta (
304    id      INT PRIMARY KEY CHECK (id = 1),
305    format  INT NOT NULL
306);
307";
308
309pub struct PgLog {
310    client: tokio_postgres::Client,
311}
312
313impl PgLog {
314    pub async fn connect(url: &str) -> Result<PgLog> {
315        let (client, connection) = tokio_postgres::connect(url, tokio_postgres::NoTls)
316            .await
317            .context("connecting to the log store")?;
318        tokio::spawn(async move {
319            if let Err(e) = connection.await {
320                tracing::error!(error = %e, "log store connection closed");
321            }
322        });
323        client
324            .batch_execute(DDL)
325            .await
326            .context("applying the log DDL")?;
327        check_format(&client).await?;
328        Ok(PgLog { client })
329    }
330
331    /// Drop and recreate the log — used by tests and by `beck run --fresh`.
332    pub async fn truncate(&self) -> Result<()> {
333        self.client
334            .batch_execute("TRUNCATE beck_log RESTART IDENTITY; TRUNCATE beck_snapshot;")
335            .await?;
336        Ok(())
337    }
338}
339
340/// Stamp the store with the encoding it holds, or refuse a store written in another one.
341///
342/// The refusal is the point. Before the log carried a format, an older store's `TEXT` bodies would
343/// have been read as `BYTEA` and decoded as postcard — producing values, not errors. §3.7 makes
344/// replay the only description of a program's history; a history that decodes to something else is
345/// the one failure mode this system cannot tolerate.
346async fn check_format(client: &tokio_postgres::Client) -> Result<()> {
347    let want = beck_core::repr::FORMAT as i32;
348    let row = client
349        .query_opt("SELECT format FROM beck_meta WHERE id = 1", &[])
350        .await?;
351    match row {
352        Some(r) => {
353            let found: i32 = r.get(0);
354            if found != want {
355                bail!(
356                    "this log was written in format {found} and this build reads format {want}. \
357                     Replay it through the older build and export, or point at a fresh store — \
358                     reading it as-is would decode to something that is not what was written"
359                );
360            }
361        }
362        None => {
363            client
364                .execute(
365                    "INSERT INTO beck_meta (id, format) VALUES (1, $1) ON CONFLICT DO NOTHING",
366                    &[&want],
367                )
368                .await?;
369        }
370    }
371    Ok(())
372}
373
374#[async_trait]
375impl LogStore for PgLog {
376    fn kind(&self) -> &'static str {
377        "postgres"
378    }
379
380    async fn head(&self) -> Result<Seq> {
381        let row = self
382            .client
383            .query_one("SELECT COALESCE(MAX(seq), 0)::BIGINT FROM beck_log", &[])
384            .await?;
385        Ok(row.get::<_, i64>(0) as u64)
386    }
387
388    async fn floor(&self) -> Result<Seq> {
389        let row = self
390            .client
391            .query_one("SELECT COALESCE(MIN(seq), 1)::BIGINT FROM beck_log", &[])
392            .await?;
393        Ok((row.get::<_, i64>(0) as u64).saturating_sub(1))
394    }
395
396    async fn append(&self, batch: &[Pending]) -> Result<Vec<Envelope>> {
397        if batch.is_empty() {
398            return Ok(Vec::new());
399        }
400        // One statement for the whole batch, inside one transaction: contiguous `seq`s,
401        // all-or-nothing (§3.7).
402        let mut sql = String::from("INSERT INTO beck_log (at, actor, body) VALUES ");
403        let mut params: Vec<Box<dyn tokio_postgres::types::ToSql + Sync + Send>> = Vec::new();
404        for (i, p) in batch.iter().enumerate() {
405            if i > 0 {
406                sql.push(',');
407            }
408            let base = i * 3;
409            sql.push_str(&format!("(${},${},${})", base + 1, base + 2, base + 3));
410            params.push(Box::new(p.at.0));
411            params.push(Box::new(p.actor.clone()));
412            params.push(Box::new(beck_core::repr::to_bytes(&p.body)?));
413        }
414        // Only `seq` comes back. The rest is already in hand, and re-reading it meant decoding
415        // every event the process had just encoded — the same work twice, on the serial path.
416        sql.push_str(" RETURNING seq");
417
418        let refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = params
419            .iter()
420            .map(|p| p.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync))
421            .collect();
422        let rows = self.client.query(&sql, &refs).await?;
423        if rows.len() != batch.len() {
424            bail!("the log accepted {} of {} events", rows.len(), batch.len());
425        }
426        let mut out: Vec<Envelope> = rows
427            .iter()
428            .zip(batch)
429            .map(|(r, p)| Envelope {
430                seq: r.get::<_, i64>(0) as u64,
431                at: p.at,
432                actor: p.actor.clone(),
433                body: p.body.clone(),
434            })
435            .collect();
436        out.sort_by_key(|e| e.seq);
437
438        // The sequence guarantees uniqueness but not contiguity if another writer exists; a
439        // failure here means the single-writer invariant has been broken, which is not something
440        // to paper over.
441        for pair in out.windows(2) {
442            if pair[1].seq != pair[0].seq + 1 {
443                bail!("the log assigned non-contiguous seqs: a second writer is appending");
444            }
445        }
446        Ok(out)
447    }
448
449    async fn read(&self, after: Seq, limit: usize) -> Result<Vec<Envelope>> {
450        let rows = self
451            .client
452            .query(
453                "SELECT seq, at, actor, body FROM beck_log WHERE seq > $1 ORDER BY seq LIMIT $2",
454                &[&(after as i64), &(limit as i64)],
455            )
456            .await?;
457        Ok(rows
458            .iter()
459            .map(|r| Envelope {
460                seq: r.get::<_, i64>(0) as u64,
461                at: Instant(r.get::<_, i64>(1)),
462                actor: r.get(2),
463                body: beck_core::repr::from_bytes(r.get::<_, &[u8]>(3))
464                    .expect("an event this store wrote decodes"),
465            })
466            .collect())
467    }
468
469    async fn put_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
470        self.client
471            .execute(
472                "INSERT INTO beck_snapshot (seq, state) VALUES ($1, $2) \
473                 ON CONFLICT (seq) DO UPDATE SET state = EXCLUDED.state",
474                &[
475                    &(snapshot.seq as i64),
476                    &beck_core::repr::to_bytes(&snapshot.state)?,
477                ],
478            )
479            .await?;
480        Ok(())
481    }
482
483    async fn snapshot_at_or_before(&self, seq: Seq) -> Result<Option<Snapshot>> {
484        let rows = self
485            .client
486            .query(
487                "SELECT seq, state FROM beck_snapshot WHERE seq <= $1 ORDER BY seq DESC LIMIT 1",
488                &[&(seq as i64)],
489            )
490            .await?;
491        match rows.first() {
492            None => Ok(None),
493            Some(r) => Ok(Some(Snapshot {
494                seq: r.get::<_, i64>(0) as u64,
495                state: beck_core::repr::from_bytes(r.get::<_, &[u8]>(1))
496                    .context("decoding a snapshot")?,
497            })),
498        }
499    }
500}
501
502// ---------------------------------------------------------------------------------------------
503// SQLite — the substrate that makes rungs 0-2 the same shape as production (§7.8.1)
504// ---------------------------------------------------------------------------------------------
505
506/// The same three tables, in SQLite's dialect.
507///
508/// `INTEGER PRIMARY KEY` is the one spelling SQLite aliases to its own `rowid`, which is what makes
509/// it monotonic and gap-free for an append-only table — the property `BIGSERIAL` gives above. Any
510/// other integer type would be an ordinary column with a btree beside it.
511///
512/// There is no BRIN analogue and none is wanted: the `rowid` **is** the physical order, so
513/// `WHERE seq > ? ORDER BY seq LIMIT ?` is already a range scan of the table itself.
514pub const SQLITE_DDL: &str = "\
515CREATE TABLE IF NOT EXISTS beck_log (
516    seq   INTEGER PRIMARY KEY,
517    at    INTEGER NOT NULL,
518    actor TEXT    NOT NULL,
519    body  BLOB    NOT NULL
520);
521CREATE TABLE IF NOT EXISTS beck_snapshot (
522    seq   INTEGER PRIMARY KEY,
523    state BLOB    NOT NULL
524);
525CREATE TABLE IF NOT EXISTS beck_meta (
526    id      INTEGER PRIMARY KEY CHECK (id = 1),
527    format  INTEGER NOT NULL
528);
529";
530
531/// How much a commit promises, which is a *semantic* choice rather than a tuning knob.
532///
533/// [`03`](../../../../../docs/03-type-and-effect-system.md) §3.7 makes the log the only description of
534/// a program's history, so "an accepted event may be lost" is a change to what the system means and
535/// not a setting. It is therefore named, defaulted to the strong option, and measured
536/// (`docs/67` §67.3) — because the cost of the strong option is 38× and a number that large deserves
537/// to be visible rather than discovered.
538#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
539pub enum Durability {
540    /// `synchronous = FULL`: every commit is on the platter before it is acknowledged.
541    ///
542    /// The same promise redb and Postgres make, which is what makes them comparable.
543    #[default]
544    Fsync,
545    /// `synchronous = NORMAL` under WAL: a commit survives a process crash and may be lost in a
546    /// power loss. **Not corrupted** — WAL rules that out — but an acknowledged event can vanish.
547    ///
548    /// Legitimate for a laptop where the log is a scratchpad, and never the default, because a
549    /// weaker promise arrived at by not reading a manual is not a decision anybody made.
550    Relaxed,
551}
552
553/// A log in a single SQLite file.
554///
555/// [`docs/07`](../../../../../docs/07-dependencies.md) §7.8.1 gives the reason, and it is not speed:
556/// SQLite is *also* a read-model engine, so "append and project in one transaction" — the property
557/// Postgres gives production — becomes available on a laptop. `redb` cannot offer that at any
558/// speed, because it has no query language for a projection to be written in.
559///
560/// The connection is behind a `Mutex` rather than a pool. One writer is the invariant §3.7 already
561/// depends on, and SQLite serialises writers anyway; a pool would add contention to model
562/// concurrency the log does not have.
563pub struct SqliteLog {
564    conn: std::sync::Mutex<rusqlite::Connection>,
565    kind: &'static str,
566}
567
568impl SqliteLog {
569    /// Open a log that makes the same durability promise redb and Postgres do.
570    pub fn open(path: &std::path::Path) -> Result<SqliteLog> {
571        Self::open_with(path, Durability::default())
572    }
573
574    pub fn open_with(path: &std::path::Path, durability: Durability) -> Result<SqliteLog> {
575        if let Some(parent) = path.parent() {
576            if !parent.as_os_str().is_empty() {
577                std::fs::create_dir_all(parent).context("creating the log directory")?;
578            }
579        }
580        let conn = rusqlite::Connection::open(path).context("opening the log store")?;
581        // The durability is part of the name, because `kind()` is what every measurement and every
582        // report labels its number with — and a row that said only "sqlite" would let two very
583        // different promises share a line (`docs/67` §67.3).
584        let kind = match durability {
585            Durability::Fsync => "sqlite",
586            Durability::Relaxed => "sqlite-relaxed",
587        };
588        Self::prepare(conn, kind, durability)
589    }
590
591    /// A log that never touches a disk — the same engine and the same SQL, for tests.
592    ///
593    /// Durability is meaningless here and the strong setting is still passed, so an in-memory run
594    /// and an on-disk run differ in exactly one thing: the disk.
595    pub fn in_memory() -> Result<SqliteLog> {
596        let conn = rusqlite::Connection::open_in_memory().context("opening the log store")?;
597        Self::prepare(conn, "sqlite-memory", Durability::Fsync)
598    }
599
600    fn prepare(
601        conn: rusqlite::Connection,
602        kind: &'static str,
603        durability: Durability,
604    ) -> Result<SqliteLog> {
605        // WAL, because a reader must not block the single writer — which is the whole shape of a
606        // log that is appended to while it is being replayed. WAL is independent of the durability
607        // choice below: it decides who blocks whom, and `synchronous` decides what a commit
608        // promises.
609        conn.pragma_update(None, "journal_mode", "WAL")
610            .context("enabling WAL")?;
611        conn.pragma_update(
612            None,
613            "synchronous",
614            match durability {
615                Durability::Fsync => "FULL",
616                Durability::Relaxed => "NORMAL",
617            },
618        )
619        .context("setting synchronous")?;
620        conn.pragma_update(None, "foreign_keys", true).ok();
621        conn.execute_batch(SQLITE_DDL)
622            .context("applying the log DDL")?;
623
624        // The same refusal `check_format` makes for Postgres, and for the same reason: a log read
625        // back under a different encoding does not fail, it produces plausible nonsense.
626        let want = beck_core::repr::FORMAT as i64;
627        let found: Option<i64> = conn
628            .query_row("SELECT format FROM beck_meta WHERE id = 1", [], |r| {
629                r.get(0)
630            })
631            .optional()
632            .context("reading the store's format")?;
633        match found {
634            Some(found) if found != want => bail!(
635                "this log was written in format {found} and this build reads format {want}. \
636                 Replay it through the older build and export, or point at a fresh store — \
637                 reading it as-is would decode to something that is not what was written"
638            ),
639            Some(_) => {}
640            None => {
641                conn.execute("INSERT INTO beck_meta (id, format) VALUES (1, ?1)", [want])
642                    .context("stamping the store's format")?;
643            }
644        }
645        Ok(SqliteLog {
646            conn: std::sync::Mutex::new(conn),
647            kind,
648        })
649    }
650
651    /// Drop and recreate the log — the counterpart of [`PgLog::truncate`].
652    pub fn truncate(&self) -> Result<()> {
653        let conn = self.conn.lock().expect("the log mutex is not poisoned");
654        conn.execute_batch("DELETE FROM beck_log; DELETE FROM beck_snapshot;")?;
655        Ok(())
656    }
657}
658
659#[async_trait]
660impl LogStore for SqliteLog {
661    fn kind(&self) -> &'static str {
662        self.kind
663    }
664
665    async fn head(&self) -> Result<Seq> {
666        let conn = self.conn.lock().expect("the log mutex is not poisoned");
667        let head: i64 = conn.query_row("SELECT COALESCE(MAX(seq), 0) FROM beck_log", [], |r| {
668            r.get(0)
669        })?;
670        Ok(head as u64)
671    }
672
673    async fn floor(&self) -> Result<Seq> {
674        let conn = self.conn.lock().expect("the log mutex is not poisoned");
675        let floor: i64 = conn.query_row("SELECT COALESCE(MIN(seq), 1) FROM beck_log", [], |r| {
676            r.get(0)
677        })?;
678        Ok((floor as u64).saturating_sub(1))
679    }
680
681    async fn append(&self, batch: &[Pending]) -> Result<Vec<Envelope>> {
682        if batch.is_empty() {
683            return Ok(Vec::new());
684        }
685        // Encode before taking the lock: `to_bytes` can fail, and a transaction opened and then
686        // abandoned on an encoding error is a transaction that has to be reasoned about.
687        let bodies: Vec<Vec<u8>> = batch
688            .iter()
689            .map(|p| beck_core::repr::to_bytes(&p.body))
690            .collect::<Result<_, _>>()?;
691
692        let mut conn = self.conn.lock().expect("the log mutex is not poisoned");
693        let tx = conn
694            .transaction()
695            .context("opening the append transaction")?;
696        // The first `seq` is decided inside the transaction, so a concurrent writer cannot
697        // interleave: contiguous, all-or-nothing (§3.7).
698        let head: i64 = tx.query_row("SELECT COALESCE(MAX(seq), 0) FROM beck_log", [], |r| {
699            r.get(0)
700        })?;
701        {
702            let mut stmt = tx
703                .prepare("INSERT INTO beck_log (seq, at, actor, body) VALUES (?1, ?2, ?3, ?4)")
704                .context("preparing the append")?;
705            for (i, (p, body)) in batch.iter().zip(&bodies).enumerate() {
706                stmt.execute(rusqlite::params![
707                    head + 1 + i as i64,
708                    p.at.0,
709                    &p.actor,
710                    body
711                ])?;
712            }
713        }
714        tx.commit().context("committing the append")?;
715
716        Ok(batch
717            .iter()
718            .enumerate()
719            .map(|(i, p)| Envelope {
720                seq: (head + 1 + i as i64) as u64,
721                at: p.at,
722                actor: p.actor.clone(),
723                body: p.body.clone(),
724            })
725            .collect())
726    }
727
728    async fn read(&self, after: Seq, limit: usize) -> Result<Vec<Envelope>> {
729        let conn = self.conn.lock().expect("the log mutex is not poisoned");
730        let mut stmt = conn.prepare(
731            "SELECT seq, at, actor, body FROM beck_log WHERE seq > ?1 ORDER BY seq LIMIT ?2",
732        )?;
733        let rows = stmt.query_map(rusqlite::params![after as i64, limit as i64], |r| {
734            Ok((
735                r.get::<_, i64>(0)?,
736                r.get::<_, i64>(1)?,
737                r.get::<_, String>(2)?,
738                r.get::<_, Vec<u8>>(3)?,
739            ))
740        })?;
741        let mut out = Vec::new();
742        for row in rows {
743            let (seq, at, actor, body) = row?;
744            out.push(Envelope {
745                seq: seq as u64,
746                at: Instant(at),
747                actor,
748                body: beck_core::repr::from_bytes(&body).context("decoding an event")?,
749            });
750        }
751        Ok(out)
752    }
753
754    async fn put_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
755        let state = beck_core::repr::to_bytes(&snapshot.state)?;
756        let conn = self.conn.lock().expect("the log mutex is not poisoned");
757        conn.execute(
758            "INSERT INTO beck_snapshot (seq, state) VALUES (?1, ?2) \
759             ON CONFLICT (seq) DO UPDATE SET state = excluded.state",
760            rusqlite::params![snapshot.seq as i64, state],
761        )?;
762        Ok(())
763    }
764
765    async fn snapshot_at_or_before(&self, seq: Seq) -> Result<Option<Snapshot>> {
766        let conn = self.conn.lock().expect("the log mutex is not poisoned");
767        let row: Option<(i64, Vec<u8>)> = conn
768            .query_row(
769                "SELECT seq, state FROM beck_snapshot WHERE seq <= ?1 ORDER BY seq DESC LIMIT 1",
770                [seq as i64],
771                |r| Ok((r.get(0)?, r.get(1)?)),
772            )
773            .optional()?;
774        match row {
775            None => Ok(None),
776            Some((seq, state)) => Ok(Some(Snapshot {
777                seq: seq as u64,
778                state: beck_core::repr::from_bytes(&state).context("decoding a snapshot")?,
779            })),
780        }
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use beck_core::Value;
788
789    fn pending(actor: &str, n: i64) -> Pending {
790        Pending {
791            at: Instant(n),
792            actor: actor.into(),
793            body: Value::Int(n),
794        }
795    }
796
797    async fn contract(store: &dyn LogStore) {
798        assert_eq!(store.head().await.unwrap(), 0);
799        let batch = vec![pending("alice", 1), pending("alice", 2)];
800        let out = store.append(&batch).await.unwrap();
801        assert_eq!(out.len(), 2);
802        assert_eq!(out[0].seq, 1);
803        assert_eq!(out[1].seq, 2, "a batch lands at contiguous seqs");
804        assert_eq!(store.head().await.unwrap(), 2);
805
806        let read = store.read(0, 10).await.unwrap();
807        assert_eq!(read.len(), 2);
808        assert_eq!(read[0].event().unwrap(), Value::Int(1));
809        assert_eq!(store.read(1, 10).await.unwrap().len(), 1);
810
811        store
812            .put_snapshot(&Snapshot {
813                seq: 2,
814                state: Value::str_("s"),
815            })
816            .await
817            .unwrap();
818        let snap = store.snapshot_at_or_before(5).await.unwrap().unwrap();
819        assert_eq!(snap.seq, 2);
820        assert_eq!(snap.state, Value::str_("s"));
821        assert!(store.snapshot_at_or_before(1).await.unwrap().is_none());
822    }
823
824    #[tokio::test]
825    async fn memory_keeps_the_contract() {
826        contract(&MemoryLog::new()).await;
827    }
828
829    /// The same contract against a real Postgres — the substrate `beck run --store postgres`
830    /// uses. No `BECK_PG` is a skip (right on a laptop); `BECK_REQUIRE_PG=1` makes the absence a
831    /// failure, so the gate cannot go silently missing from CI (the conformance suite's pattern).
832    #[tokio::test]
833    async fn postgres_keeps_the_same_contract() {
834        let required = std::env::var("BECK_REQUIRE_PG").is_ok_and(|v| v == "1");
835        let Ok(url) = std::env::var("BECK_PG") else {
836            assert!(
837                !required,
838                "BECK_REQUIRE_PG=1 is set, so a missing BECK_PG is a failure rather than a skip"
839            );
840            eprintln!("skipping: set BECK_PG to run the log contract against a real Postgres");
841            return;
842        };
843        // BECK_PG being set is a claim that a server is there; an unreachable one is a failure.
844        let store = PgLog::connect(&url)
845            .await
846            .expect("BECK_PG is set, so Postgres must be reachable");
847        store.truncate().await.expect("a fresh log");
848        contract(&store).await;
849    }
850
851    #[tokio::test]
852    async fn redb_keeps_the_same_contract() {
853        let dir = std::env::temp_dir().join(format!("beck-rt-log-{}", std::process::id()));
854        let _ = std::fs::remove_file(&dir);
855        let store = RedbLog::open(&dir).unwrap();
856        contract(&store).await;
857        drop(store);
858        let _ = std::fs::remove_file(&dir);
859    }
860
861    #[tokio::test]
862    async fn redb_survives_reopening() {
863        let path = std::env::temp_dir().join(format!("beck-rt-reopen-{}", std::process::id()));
864        let _ = std::fs::remove_file(&path);
865        {
866            let store = RedbLog::open(&path).unwrap();
867            store.append(&[pending("a", 7)]).await.unwrap();
868        }
869        {
870            let store = RedbLog::open(&path).unwrap();
871            assert_eq!(store.head().await.unwrap(), 1);
872            assert_eq!(
873                store.read(0, 10).await.unwrap()[0].event().unwrap(),
874                Value::Int(7)
875            );
876        }
877        let _ = std::fs::remove_file(&path);
878    }
879
880    /// SQLite answers the same contract, on disk and in memory.
881    ///
882    /// Unlike Postgres this needs no server and therefore **never skips** — which is half of
883    /// `docs/07` §7.8.1's argument: the substrate that makes a laptop the same shape as production
884    /// is also the one CI can always run.
885    #[tokio::test]
886    async fn sqlite_keeps_the_same_contract() {
887        contract(&SqliteLog::in_memory().unwrap()).await;
888
889        let path = std::env::temp_dir().join(format!("beck-rt-sqlite-{}.db", std::process::id()));
890        for suffix in ["", "-wal", "-shm"] {
891            let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
892        }
893        let store = SqliteLog::open(&path).unwrap();
894        contract(&store).await;
895        drop(store);
896        for suffix in ["", "-wal", "-shm"] {
897            let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
898        }
899    }
900
901    #[tokio::test]
902    async fn sqlite_survives_reopening() {
903        let path =
904            std::env::temp_dir().join(format!("beck-rt-sqlite-reopen-{}.db", std::process::id()));
905        for suffix in ["", "-wal", "-shm"] {
906            let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
907        }
908        {
909            let store = SqliteLog::open(&path).unwrap();
910            store.append(&[pending("a", 7)]).await.unwrap();
911            store
912                .put_snapshot(&Snapshot {
913                    seq: 1,
914                    state: Value::Int(70),
915                })
916                .await
917                .unwrap();
918        }
919        {
920            let store = SqliteLog::open(&path).unwrap();
921            assert_eq!(store.head().await.unwrap(), 1);
922            assert_eq!(
923                store.read(0, 10).await.unwrap()[0].event().unwrap(),
924                Value::Int(7)
925            );
926            // The snapshot too: a store that persisted its log and lost its snapshots would replay
927            // correctly and slowly, which is the failure nobody notices.
928            assert_eq!(
929                store.snapshot_at_or_before(5).await.unwrap().unwrap().state,
930                Value::Int(70)
931            );
932            // And appending after a reopen continues the sequence rather than restarting it.
933            let out = store.append(&[pending("b", 8)]).await.unwrap();
934            assert_eq!(out[0].seq, 2, "a reopened log continues its own sequence");
935        }
936        for suffix in ["", "-wal", "-shm"] {
937            let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
938        }
939    }
940
941    /// The property `docs/07` §7.8.1 is actually about, and the one neither redb nor a file can
942    /// offer: **an append and a projection in one transaction**.
943    ///
944    /// Postgres has it in production. This is the claim that a laptop now has the same shape — not
945    /// that the read models are built (they are not, `docs/23` §23.19), but that the substrate
946    /// underneath them admits the property. A `LogStore` cannot express it, so the test reaches
947    /// past the trait to the connection, which is the only honest way to assert it today.
948    #[tokio::test]
949    async fn sqlite_can_append_and_project_in_one_transaction() {
950        let store = SqliteLog::in_memory().unwrap();
951        {
952            let conn = store.conn.lock().unwrap();
953            conn.execute_batch("CREATE TABLE counts (actor TEXT PRIMARY KEY, n INTEGER NOT NULL);")
954                .unwrap();
955        }
956
957        // One transaction: the event lands and the read model moves, or neither does.
958        {
959            let mut conn = store.conn.lock().unwrap();
960            let tx = conn.transaction().unwrap();
961            tx.execute(
962                "INSERT INTO beck_log (seq, at, actor, body) VALUES (1, 1, 'ana', ?1)",
963                [beck_core::repr::to_bytes(&Value::Int(1)).unwrap()],
964            )
965            .unwrap();
966            tx.execute(
967                "INSERT INTO counts (actor, n) VALUES ('ana', 1) \
968                 ON CONFLICT (actor) DO UPDATE SET n = n + 1",
969                [],
970            )
971            .unwrap();
972            tx.commit().unwrap();
973        }
974        assert_eq!(store.head().await.unwrap(), 1);
975        {
976            let conn = store.conn.lock().unwrap();
977            let n: i64 = conn
978                .query_row("SELECT n FROM counts WHERE actor = 'ana'", [], |r| r.get(0))
979                .unwrap();
980            assert_eq!(n, 1);
981        }
982
983        // And a rolled-back transaction leaves neither behind, which is the half that makes the
984        // first half worth anything.
985        {
986            let mut conn = store.conn.lock().unwrap();
987            let tx = conn.transaction().unwrap();
988            tx.execute(
989                "INSERT INTO beck_log (seq, at, actor, body) VALUES (2, 2, 'ana', ?1)",
990                [beck_core::repr::to_bytes(&Value::Int(2)).unwrap()],
991            )
992            .unwrap();
993            tx.execute("UPDATE counts SET n = n + 1 WHERE actor = 'ana'", [])
994                .unwrap();
995            tx.rollback().unwrap();
996        }
997        assert_eq!(
998            store.head().await.unwrap(),
999            1,
1000            "the rolled-back event is not in the log"
1001        );
1002        let conn = store.conn.lock().unwrap();
1003        let n: i64 = conn
1004            .query_row("SELECT n FROM counts WHERE actor = 'ana'", [], |r| r.get(0))
1005            .unwrap();
1006        assert_eq!(n, 1, "and the projection did not move either");
1007    }
1008
1009    /// A SQLite log written under another encoding is refused, the same way redb's is.
1010    #[test]
1011    fn a_sqlite_log_written_in_another_format_is_refused_too() {
1012        let path = std::env::temp_dir().join(format!("beck-sqlite-fmt-{}.db", std::process::id()));
1013        for suffix in ["", "-wal", "-shm"] {
1014            let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
1015        }
1016        {
1017            let conn = rusqlite::Connection::open(&path).unwrap();
1018            conn.execute_batch(SQLITE_DDL).unwrap();
1019            conn.execute(
1020                "INSERT INTO beck_meta (id, format) VALUES (1, ?1)",
1021                [beck_core::repr::FORMAT as i64 + 1],
1022            )
1023            .unwrap();
1024        }
1025        let Err(err) = SqliteLog::open(&path) else {
1026            panic!("a log in another format has to be refused")
1027        };
1028        let text = format!("{err:#}");
1029        assert!(text.contains("was written in format"), "{text}");
1030        for suffix in ["", "-wal", "-shm"] {
1031            let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
1032        }
1033    }
1034
1035    #[test]
1036    fn a_log_written_in_another_format_is_refused_rather_than_misread() {
1037        // The one failure an append-only history may not have. Postcard is not self-describing, so
1038        // handing it a Phase 2 log's JSON text does not error — it decodes to *a* `Repr`, and the
1039        // program's history silently becomes something nobody wrote. The stamp is what turns that
1040        // into a refusal.
1041        let dir = std::env::temp_dir().join("beck-format-stamp");
1042        let _ = std::fs::remove_dir_all(&dir);
1043        std::fs::create_dir_all(&dir).expect("a temp dir");
1044        let path = dir.join("log.redb");
1045
1046        // A store this build wrote opens again.
1047        {
1048            let store = RedbLog::open(&path).expect("a fresh log opens");
1049            drop(store);
1050        }
1051        RedbLog::open(&path).expect("and opens again");
1052
1053        // Rewrite the stamp as if an older build had made it.
1054        {
1055            let db = redb::Database::create(&path).expect("reopen");
1056            let tx = db.begin_write().expect("write");
1057            {
1058                let mut meta = tx.open_table(META).expect("meta");
1059                meta.insert("format", beck_core::repr::FORMAT - 1)
1060                    .expect("stamp");
1061            }
1062            tx.commit().expect("commit");
1063        }
1064        let said = match RedbLog::open(&path) {
1065            Ok(_) => panic!("a foreign format must be refused"),
1066            Err(e) => e.to_string(),
1067        };
1068        assert!(said.contains("format"), "{said}");
1069        assert!(
1070            said.contains("not what was written"),
1071            "the message has to say what the risk is, not just that it stopped: {said}"
1072        );
1073        let _ = std::fs::remove_dir_all(&dir);
1074    }
1075
1076    #[test]
1077    fn an_unstamped_log_with_events_in_it_is_refused_too() {
1078        // The upgrade path from Phase 2, which stamped nothing: an empty unstamped store is one
1079        // this build just made, and an unstamped store *with events* is somebody's history in the
1080        // old encoding.
1081        let dir = std::env::temp_dir().join("beck-format-unstamped");
1082        let _ = std::fs::remove_dir_all(&dir);
1083        std::fs::create_dir_all(&dir).expect("a temp dir");
1084        let path = dir.join("log.redb");
1085        {
1086            let db = redb::Database::create(&path).expect("create");
1087            let tx = db.begin_write().expect("write");
1088            {
1089                let mut events = tx.open_table(EVENTS).expect("events");
1090                events
1091                    .insert(1u64, b"{\"seq\":1}".as_slice())
1092                    .expect("an old record");
1093                let _ = tx.open_table(SNAPSHOTS).expect("snapshots");
1094                let _ = tx.open_table(META).expect("meta");
1095            }
1096            tx.commit().expect("commit");
1097        }
1098        let said = match RedbLog::open(&path) {
1099            Ok(_) => panic!("an unstamped log with events must be refused"),
1100            Err(e) => e.to_string(),
1101        };
1102        assert!(said.contains("no format stamp"), "{said}");
1103        let _ = std::fs::remove_dir_all(&dir);
1104    }
1105}