1use std::sync::Mutex;
19
20use anyhow::{bail, Context, Result};
21use async_trait::async_trait;
22use redb::ReadableTable;
23use rusqlite::OptionalExtension;
24
25pub use beck_host::record::{Envelope, Instant, Pending, Seq, Snapshot};
29
30#[async_trait]
31pub trait LogStore: Send + Sync + 'static {
32 fn kind(&self) -> &'static str;
35 async fn head(&self) -> Result<Seq>;
36 async fn floor(&self) -> Result<Seq>;
37 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#[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
134const EVENTS: redb::TableDefinition<u64, &[u8]> = redb::TableDefinition::new("events");
139const SNAPSHOTS: redb::TableDefinition<u64, &[u8]> = redb::TableDefinition::new("snapshots");
140const 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 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 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 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 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
278pub 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 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
340async 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 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 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 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
502pub 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#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
539pub enum Durability {
540 #[default]
544 Fsync,
545 Relaxed,
551}
552
553pub struct SqliteLog {
564 conn: std::sync::Mutex<rusqlite::Connection>,
565 kind: &'static str,
566}
567
568impl SqliteLog {
569 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 let kind = match durability {
585 Durability::Fsync => "sqlite",
586 Durability::Relaxed => "sqlite-relaxed",
587 };
588 Self::prepare(conn, kind, durability)
589 }
590
591 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 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 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 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 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 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 #[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 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 #[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 assert_eq!(
929 store.snapshot_at_or_before(5).await.unwrap().unwrap().state,
930 Value::Int(70)
931 );
932 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 #[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 {
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 {
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 #[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 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 {
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 {
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 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}